类型脚本中索引签名对象类型的类型安全合并

时间:2020-01-10 21:11:06

标签: typescript merge spread-syntax

This question and answer covers object literals,但使用索引签名对象类型时答案不起作用。例如:

type UniqueObject<T, U> = { [K in keyof U]: K extends keyof T ? never : U[K] }

export function mergeUnique <T, U, V> (
  a: T,
  b?: UniqueObject<T, U>,
  c?: UniqueObject<T & U, V>,
) {
  return {
    ...a,
    ...b,
    ...c,
  }
}

type Obj = { [index: string]: number | undefined }
const a: Obj = { a: undefined }
const b: Obj = { b: 3 }

// should all pass
const res01 = mergeUnique({ a: undefined }, { b: 3 })
const res02 = mergeUnique({ a: undefined }, b)
const res03 = mergeUnique(a, { b: 3 })                 // errors incorrectly ❌ `Type 'number' is not assignable to type 'never'`
const res04 = mergeUnique(a, b)                        // errors incorrectly ❌ `Type 'undefined' is not assignable to type 'never'`
const res05 = mergeUnique({ b: 3 }, { a: undefined })
const res06 = mergeUnique(b, { a: undefined })         // errors incorrectly ❌ `Type 'undefined' is not assignable to type 'never'`
const res07 = mergeUnique({ b: 3 }, a)
const res08 = mergeUnique(b, a)                        // errors incorrectly ❌ `Argument of type 'Obj' is not assignable to parameter of type 'UniqueObject<Obj, { [x: string]: ...; }>'`

// should all fail
const res09 = mergeUnique({ a: undefined }, { a: undefined })
const res10 = mergeUnique({ a: undefined }, a)         // passes incorrectly ❌
const res11 = mergeUnique(a, { a: undefined })
const res12 = mergeUnique(a, a)                        // errors correctly ? but reason wrong: `Argument of type 'Obj' is not assignable to parameter of type 'UniqueObject<Obj, { [x: string]: ...; }>'`

Code

1 个答案:

答案 0 :(得分:1)

尽管有一些使用索引签名操作类型的技术(例如,请参见this answer),但是您想要在此处进行的特定检查是不可能的。如果将一个值注释为string类型,那么即使您使用字符串文字对其进行初始化,编译器也不会将其范围缩小到string literal type

const str: string = "hello"; // irretrievably widened to string
let onlyHello: "hello" = "hello";
onlyHello = str; //error! string is not assignable to "hello"

在上面,string变量str被初始化为"hello",但是您不能将其分配给类型为"hello"的变量。编译器永久忘记了str的值是字符串文字"hello"

对于任何非工会类型的注释,此“健忘”的扩展均适用。如果类型为联合,则编译器实际上将在分配时缩小变量的类型,至少直到重新分配变量为止:

const strOrNum: string | number = "hello"; // narrowed from string | number to string
let onlyString: string = "hello";
onlyString = strOrNum; // okay, strOrNum is known to be string

很不幸,您的Obj类型是非工会类型。而且,由于它具有string索引签名,因此编译器将仅知道标注为Obj的变量将具有string键,并且将不记住这些键的字面值,即使它用带有字符串文字键的对象文字初始化:

const obj: Obj = { a: 1, b: 2 }; // irretrievably widened to Obj
let onlyAB: { a: 1, b: 1 } = { a: 1, b: 1 };
onlyAB = obj; // error! Obj is missing a and b

因此,已被注释为a类型的bObj变量对于编译器来说仅是Obj类型。它忘记了其中的任何单个属性。从类型系统的角度来看,ab是相同的。

因此,无论我尝试使用mergeUnique()的签名进行哪种疯狂的类型游戏,我都无能为力,以致mergeUnique(a, b)成功而mergeUnique(a, a)失败; ab的类型是相同的非工会类型;编译器无法区分它们。


如果您希望编译器记住ab上的各个键,则不应注释它们,而应让编译器推断它们。如果要确保ab可分配给Obj而不实际将它们扩展到Obj,则可以使用generic辅助函数来做到这一点:

const asObj = <T extends Obj>(t: T) => t;

函数asObj()仅返回与参数相同的值,并且不更改其推断的类型。但是由于TconstrainedObj,所以只有将对象分配给Obj才能成功:

const a = asObj({ a: undefined }); // {a: undefined}
const b = asObj({ b: 3 }); // {b: number}
const c = asObj({ c: "oopsie" }); // error!

现在,您拥有ab的窄类型,这些字符串具有已知的字符串文字属性键,(还有c带有编译器错误,因为"oopsie"不是一个数字|未定义)。因此,其余代码的行为符合预期:

// these all succeed
const res01 = mergeUnique({ a: undefined }, { b: 3 })
const res02 = mergeUnique({ a: undefined }, b)
const res03 = mergeUnique(a, { b: 3 })
const res04 = mergeUnique(a, b)
const res05 = mergeUnique({ b: 3 }, { a: undefined })
const res06 = mergeUnique(b, { a: undefined })
const res07 = mergeUnique({ b: 3 }, a)
const res08 = mergeUnique(b, a)
// these all fail
const res09 = mergeUnique({ a: undefined }, { a: undefined })
const res10 = mergeUnique({ a: undefined }, a)      
const res11 = mergeUnique(a, { a: undefined })
const res12 = mergeUnique(a, a)                    

好的,希望能有所帮助;祝你好运!

Playground link to code