I'm trying to return a discriminated union given an objects key:
type Foo = {
key: 'foo';
value: number;
};
type Bar = {
key: 'bar';
value: string;
};
type Obj = {
foo: number;
bar: string;
};
function getField(obj: Obj, key: keyof Obj): Foo | Bar {
// Error here:
// Type '{ key: keyof Obj; value: string | number; }' is not assignable to type 'Foo | Bar'.
return { key, value: obj[key] };
}
When I try the above code I get the error: Type '{ key: keyof Obj; value: string | number; }' is not assignable to type 'Foo | Bar'.
I think I need to link the type of obj[key] to the type of key somehow but I'm not sure how to accomplish this without casting.
CodePudding user response:
You could do it like this:
type Foo = {
key: 'foo';
value: number;
};
type Bar = {
key: 'bar';
value: string;
};
type Obj = {
foo: number;
bar: string;
};
function getField(obj: Record<'foo' | 'bar', any>, key: 'foo' | 'bar'): Foo | Bar {
return { key, value: obj[key] };
}
CodePudding user response:
Even though it is true that the return value of getField() will definitely be either a Foo or a Bar, the compiler cannot see it. The key parameter is of a union type "foo" | "bar", and therefore obj[key] is also of a union type, number | string. These types are the correct types. But the types of key and obj[key] do not contain sufficient information to recognize that { key, value: obj[key] } will be assignable to type Foo | Bar.
If I hand you a value k of type "foo" | "bar" and a value v of type number | string, you'd have no reason to believe that { key: k, value: v } would be a valid Foo or a Bar. It's quite possible that k is "foo" while v is some string.
Now, we happen to know that key and obj[key] are correlated in a way that's not captured by looking at their types separately. If key is "foo" then obj[key] is number, and if key is "bar", then obj[key] is string. But unfortunately the compiler only sees the separate types, the same as the k and v example from before.
It would be nice if you could tell the compiler to consider the "foo" situation separately from the "bar" situation, but that just can't happen in a single line of code with expressions of union types.
This issue with correlated union types is the subject of microsoft/TypeScript#30581.
TypeScript 4.6 introduced some improvements in microsoft/TypeScript#47109 to address this issue.
The general approach is refactor to use a generic function, so that the single line of code can be evaluated for just "foo" or just "bar". That is, we make it generic in K extends keyof Obj. And we must also refactor the types so that the compiler can see Foo and Bar being some generic function of K.
Here is the refactoring necessary:
type FooBar<K extends keyof Obj> = { [P in K]: { key: P, value: Obj[P] } }[K];
type Foo = FooBar<"foo">;
type Bar = FooBar<"bar">;
The FooBar<K> type is a distributive object type as coined in microsoft/TypeScript#47109. The type FooBar<"foo"> is the same as your original Foo, and FooBar<"bar"> is the same as Bar. And FooBar<keyof Obj> is the same as Foo | Bar.
Now you can write getField() like this:
function getField<K extends keyof Obj>(obj: Obj, key: K): FooBar<K> {
return { key, value: obj[key] }; // okay
}
And verify that it works as desired:
const obj: Obj = { foo: 1, bar: "x" };
const f: Foo = getField(obj, "foo");
const b: Bar = getField(obj, "bar");
Hooray!
Note well: it is important that FooBar is written in terms of a generic {key: P, value: Obj[P]} and that getField() is implemented with the analogous {key: key, value: obj[key]}. If you rewrite FooBar to be unrelated to the Obj type, such as, for example,
type FooBar<K extends keyof Obj> = Extract<Foo | Bar, { key: K }>;
then you would get the same error again:
function getField<K extends keyof Obj>(obj: Obj, key: K): FooBar<K> {
return { key, value: obj[key] }; // error!
}
