打字稿中索引签名对象类型的类型安全合并

Type safe merge of index signature object types in typescript

但在使用索引签名对象类型时答案不起作用。例如:

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

尽管有一些技术可以使用索引签名来操作类型(参见 for an example), the specific check you want to happen here is not possible. If a value is annotated to be of type string, then the compiler will not narrow it down to astring 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

因此,您的 ab 变量被注释为 Obj 类型,编译器只知道它们是 Obj 类型。它忘记了其中的任何个人属性。从类型系统的角度来看,ab 是相同的。

因此,无论我尝试使用 mergeUnique() 的签名玩什么疯狂类型的游戏,我都无法做到 mergeUnique(a, b) 成功而 mergeUnique(a, a) 失败; ab 的类型是相同的非联合类型;编译器无法区分它们。


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

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

函数 asObj() 只是 returns 它作为参数接收的相同值,并且不会更改其推断类型。但是由于 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