I want to create a function that takes a generic argument - key of tagged union - and value - value of said union - and create an instance of this union. Here is an example
type TagA = 'x' | 'y' | 'z';
type TagB = 'm' | 'n';
type UnionExampleA =
| { tag: 'x', value: number}
| { tag: 'y', value: [number, number]}
| { tag: 'z', value: string}
type UnionExampleB =
| { tag: 'm', value: string}
| { tag: 'n', value: null}
type UnionKey<T> = T extends { tag: infer R} ? R : never;
type UnionValue<T> = T extends { value: infer R} ? R : never;
function foo<UnionType>(tag: UnionKey<UnionType>, value: UnionValue<UnionType>): UnionType {
return {
tag,
value
}
}
const a = foo<UnionExampleA>('y', [15, 25]);
const b = foo<UnionExampleB>('m', '25');
// should not compile
// const c = foo<UnionExampleB>('y', [15, 25]);
So we have some sample types and calling example. So we want to pass y
as key and [15, 25]
as value and get {'y': [15, 25]}
instance of UnionExampleA
type. Same for UnionExampleB
in second case.
CodePudding user response:
The compiler can't follow your logic; technically it would be possible for the type argument to have more properties than just tag
and value
, so it's unsafe to return {tag, value}
anyway.
If you want to write something the compiler can follow, and also keep it so that the function is generic in only a single type parameter which you intend to manually instantiate when you call the function, then the following is the best I can do:
type Mapping<U extends { tag: string, value: any }> =
{ [T in U as T['tag']]: T["value"] };
type TagValueToTuple<U extends { tag: string, value: any }> =
{ [P in keyof Mapping<U>]: [tag: P, value: Mapping<U>[P]] }[keyof Mapping<U>];
type PickTagValue<U extends { tag: string, value: any }> =
{ [P in keyof Mapping<U>]: { tag: P, value: Mapping<U>[P] } }[keyof Mapping<U>];
function foo<U extends { tag: string, value: any }>(
...[tag, value]: TagValueToTuple<U>
): PickTagValue<U> {
return { tag, value };
}
So foo
is still generic in one type parameter U
representing the full union type, which I have constrained to a type with a string
-assignable tag
property and some value
property.
Then, it accepts a rest parameter of type TagValueToTuple<U>
, which is destructured into tag
and value
parameters. The TagValueToTuple<U>
type takes the discriminated union U
corresponding to the intended output, and turns it into a discriminated union of tuple types. For example:
type TupleExampleA = TagValueToTuple<UnionExampleA>
// type TupleExampleA =
// [tag: "x", value: number] |
// [tag: "y", value: [number, number]] |
// [tag: "z", value: string]
And it returns a value of type PickTagValue<U>
, which is another union similar to U
except that it has only the tag
and value
properties. For example:
type PickExampleB = PickTagValue<UnionExampleB>;
// type PickExampleB =
// { tag: "m"; value: string; } |
// { tag: "n"; value: null; }
The reason why both TagValueToTuple<U>
and PickTagValue<U>
are written the way they are, in terms of Mapping<U>
, is to make them both distributive object types as coined in microsoft/TypeScript#47109. This is specifically a mapped type into which one immediately indexes to get a union. And, as per microsoft/TypeScript#47109, the compiler is able to understand the correlation between types in this way.
If you tried rewriting the types to equivalent versions but did not use distributive object types, the implementation would have errors:
type TagValueToTuple2<U extends { tag: string, value: any }> =
U extends { tag: infer T, value: infer V } ? [tag: T, value: V] : never;
type PickTagValue2<U extends { tag: string, value: any }> =
U extends unknown ? Pick<U, "tag" | "value"> : never;
function foo2<U extends { tag: string, value: any }>(
...[tag, value]: TagValueToTuple2<U>
): PickTagValue2<U> {
return { tag, value }; // error!
}
Anyway, let's make sure it still works when you call it:
const a: UnionExampleA = foo<UnionExampleA>('y', [15, 25]);
const b: UnionExampleB = foo<UnionExampleB>('m', '25');
const c = foo<UnionExampleB>('y', [15, 25]); // error!
Looks good!