Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript generic union type

I'm trying to create a simple switch function which takes a first parameter that must be an union of string & an object which have keys based on the first parameter union and can return any value.

export const mySwitch = <T extends string>(value: T, possibilities: {[key in T]: any}): any => {
    return possibilities[value];
};

Typical usage would be

let option: "val1" | "val2" | "val3" = "val1";
// should returns s1
// Impossible should be type-checked as an error since it's not part of the option union type
mySwitch(option, {val1: "s1", val2: "s2", val3: "s3", impossible: "impossible"});

My problem occurs because the generic type T must be a string in order to be used as an object key. I don't know how you can tell T to be an union of string.

I tried T extends string with no success.

like image 827
Mathieu Urstein Avatar asked Sep 19 '19 15:09

Mathieu Urstein


1 Answers

The T extends string version seems to work well. It disallows impossible, but wouldn't you want to disallow it since if the parameter can never have that value that option would be useless?:

export const mySwitch = <T extends string>(value: T, possibilities: {[key in T]: any}): any => {
    return possibilities[value];
};


declare let option: "val1" | "val2" | "val3";
mySwitch(option, {val1: "s1", val2: "s2", val3: "s3", impossible: "impossible"}); 

play

If you want to allow the extra keys you could declare the case object separately (bypassing excess property checks and allowing you to reuse the case object)


declare let option: "val1" | "val2" | "val3";
const casses = {val1: "s1", val2: "s2", val3: "s3", impossible: "impossible"}
mySwitch(option, casses); 

play

Or you could change your type a little bit so the generic type parameter is the case object, and the value will the be typed as keyof T:

export const mySwitch = <T>(value: keyof T, possibilities: T): any => {
    return possibilities[value];
};


declare let option: "val1" | "val2" | "val3";
mySwitch(option, {val1: "s1", val2: "s2", val3: "s3", impossible: "impossible"}); 

play

Also a better option would be to preserve the type from the case object instead of using any:

export const mySwitch = <T, K extends keyof T>(value: K, possibilities: T): T[K] => {
    return possibilities[value];
};


declare let option: "val1" | "val2" | "val3";
mySwitch(option, {val1: 1, val2: "s2", val3: "s3", impossible: false});  // returns string | number

play

Edit:

To preserve both correct return type and error if there are possibilities not present in union you could use this:

const mySwitch = <T extends Record<K, any>, K extends string>(value: K, possibilities: T & Record<Exclude<keyof T, K>, never>): any => {
    return possibilities[value];
};

let option: "val1" | "val2" | "val3" = (["val1", "val2", "val3"] as const)[Math.round(Math.random() * 2)]
mySwitch(option, {val1: "s1", val2: "s2", val3: "s3" });
mySwitch(option, {val1: "s1", val2: "s2", val3: "s3", impossible: "" }); //err on impossible

play

Note that because typescript does control flow analysis you need to make sure option is not just types as the actual constant you assign instead of the type annotation you specify

like image 103
Titian Cernicova-Dragomir Avatar answered Oct 19 '22 19:10

Titian Cernicova-Dragomir