问题
This question and answer covers object literals but the answer does not work when using index signature object types. e.g:
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:
Although there are some techniques to manipulate types with index signatures (see this answer 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, even if you initialize it with a string literal:
const str: string = "hello"; // irretrievably widened to string
let onlyHello: "hello" = "hello";
onlyHello = str; //error! string is not assignable to "hello"
In the above, the string
variable str
is initialized to "hello"
, but you cannot assign that to a variable of type "hello"
; the compiler has permanently forgotten that the value of str
is the string literal "hello"
.
This "forgetful" widening is true for any annotation of a non-union type. If the type is a union, the compiler will actually narrow the type of the variable on assignment, at least until the variable is reassigned:
const strOrNum: string | number = "hello"; // narrowed from string | number to string
let onlyString: string = "hello";
onlyString = strOrNum; // okay, strOrNum is known to be string
Unfortunately, your Obj
type is a non-union type. And since it has a string
index signature, the compiler will only know that a variable annotated as Obj
will have string
keys and will not remember the literal value of those keys, even if it is initialized with an object literal with string literal keys:
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
Thus your a
and b
variables, which have been annotated as type Obj
, are known to the compiler only to be of type Obj
. It has forgotten any individual properties inside them. From the type system's point of view, a
and b
are identical.
And thus no matter what crazy type games I try to play with the signature for mergeUnique()
, nothing I can do will make it so that mergeUnique(a, b)
succeeds while mergeUnique(a, a)
fails; the types of a
and b
are identical non-union types; the compiler can't tell them apart.
If you want the compiler to remember the individual keys on a
and b
, you should not annotate them but let the compiler infer them. If you want to ensure that a
and b
are assignable to Obj
without actually widening them to Obj
, you can make a generic helper function to do that:
const asObj = <T extends Obj>(t: T) => t;
The function asObj()
just returns the same value it receives as an argument, and does not change its inferred type. But since T
is constrained to Obj
, it will only succeed if the object could be assigned to Obj
:
const a = asObj({ a: undefined }); // {a: undefined}
const b = asObj({ b: 3 }); // {b: number}
const c = asObj({ c: "oopsie" }); // error!
Now you have a
and b
of narrow types with known string literal property keys, (and a c
with a compiler error because "oopsie"
is not a `number | undefined). And thus the rest of your code behaves as desired:
// 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)
Okay, hope that helps; good luck!
Playground link to code
来源:https://stackoverflow.com/questions/59689060/type-safe-merge-of-index-signature-object-types-in-typescript