Home > Mobile >  How to create an instance of tagged union given I have key and value for it
How to create an instance of tagged union given I have key and value for it

Time:12-24

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!

Playground link to code

  • Related