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!
}