In typescript I know that for type safety when you have a union of functions, if you want to call this function, you have to pass it a intersection of its parameters instead of an union, but this behavior can be annoying when you already have type checks that ensure that no matter what function of the union you’ll call, you will always pass it the right parameters even when passing an union instead of passing every possible parameters. Let me explain with a small example:
type Runner = {
a: () => void,
b: (param: { x: number }) => void,
c: (param: { y: string, z: number }) => string,
};
type Action = {
[K in keyof Runner]: { kind: K, param: Runner[K] extends () => any ? unknown : Parameters<Runner[K]>[0] }
}[keyof Runner];
const runner: Runner = {
a: () => {},
b: ({ x }) => {},
c: ({ y, z }) => "c",
};
const action = { kind: "c", param: { y: "5", z: 5 } } as Action;
runner[action.kind](action.param);
Here we can see that the param
field is tied to the kind
field which is every field of Runner
, so every possible function of the union, so we know that we will have the right parameter depending on the kind
field, but still typescript complains because you can’t call a union of function with a union of parameters. Is it possible to handle these kind of cases nicely without having to add any
casts into the mix ?
1
Not that I know of. It’s a limitation I’ve encountered many times myself. In the end just had to structure my code differently.
Typescript treats both fields of the action as all possible values of the union, until you give it smth to discriminate it by and narrow the type.
const action = { kind: "c", param: { y: "5", z: 5 } } as Action;
runner[action.kind](action.param); // error
if (action.kind === "c") {
runner[action.kind](action.param); // OK, action is narrowed to one member of the union
}
const action2:Action = { kind: "c", param: { y: "5", z: 5 } };
runner[action2.kind](action2.param); // OK, action2 is narrowed to one member of the union
This might improve in the future, as they constantly refine the control flow analysis, but for now, you have to either give TS a way to discriminate the union or cast it. I try to restructure my code to avoid casting, since casting in TS is nothing more than just shutting the compiler up, but if I have to I do it in the isolated function and then cover it with unit tests extensively.
A lot of the problems will simply be solved if you let TypeScript be smart, and remove the assertion you have done with as Action
.
Because of as Action
typescript is not able to tell that in the above example action.param
is of type { y: string, z: number }
. It instead is using a union param : unknown | { y: string, z: number } | { x: number }
, and any union with unknown
will be unknown
.
To fix this, let typescript figure out the type itself:
const action : Action = { kind: "c", param: { y: "5", z: 5 } };
const action2 : Action = { kind: "b", param: { x : 2} };
runner[action.kind](action.param);
runner[action2.kind](action2.param);
Playground
The above works for 2 cases, but then you have a special scenario with a
. You want it to work without passing any argument.
This might require changes to other tyes.
Firstly, you should start using this as the type of Action
, as it is more accurate:
type Action = {
[K in keyof Runner]: { kind: K, param: Runner[K] extends () => void ? undefined : Parameters<Runner[K]>[0] }
}[keyof Runner];
You are looking for extends () => void
, instead of extends () => any
. undefined
makes more sense as the key will not actually exist. (These are both to make it clearer)
The change that allows the below to work correctly
type Runner = {
a: (param: undefined) => void,
b: (param: { x: number }) => void,
c: (param: { y: string, z: number }) => string,
};
You have to let TS know that param
will be undefined, even if it seems that we are passing it using action.param
.
type Runner = {
a: (param : undefined) => void,
b: (param: { x: number }) => void,
c: (param: { y: string, z: number }) => string,
};
type doesExtend = Runner['a'] extends () => any ? true : false ;
type Action = {
[K in keyof Runner]: { kind: K, param: Runner[K] extends () => void ? undefined : Parameters<Runner[K]>[0] }
}[keyof Runner];
const runner: Runner = {
a: () => {},
b: ({ x }) => {},
c: ({ y, z }) => "c",
};
const action : Action = { kind: "c", param: { y: "5", z: 5 } };
const action2 : Action = { kind: "b", param: { x : 2} };
runner[action.kind](action.param);
runner[action2.kind](action2.param);
const action3 : Action = { kind: "a" , param : undefined};
runner[action3.kind](action3.param);
Playground
PS: There is an option to use never
, but with that you have to also use ?
and you type becomes:
type Action = { [K in keyof Runner]: { kind: K, param: Runner[K] extends () => void ? undefined : Parameters<Runner[K]>[0]
But you still run into the same problem unless you change type of Runner
.
1