The main issue here is that the type of handlers[action['@type']]
and the type of action
are both union types (i.e., ((action: Sum) => number) | (action: Square) => number))
and Sum | Square
), but these values are correlated to each other in a way that's not captured by those uncorrelated/independent union types. The compiler doesn't understand that, for example, you will never be passing a Square
into (action: Sum) => number
. This general issue is the subject of microsoft/TypeScript#30581.
Note that the compiler does not distribute its control flow analysis across the unions. Specifically, when analyzing
handlers[action['@type']](action)
the compiler does not do what a human being might do, and say "okay, action
is either a Sum
or a Square
. If it's a Sum
, then action['@type']
is "sum"
and so handlers[action['@type']]
is a (action: Sum) => number
and so handlers[action['@type']](action)
is a valid call and results in a number
. If it's a Square
, then action['@type']
is "square"
and so handlers[action['@type']]
is a (action: Square) => number
and so handlers[action['@type']](action)
is a valid cal and results in a number
. In both cases we get a number
, so the result is a number
." Such analysis would require that the compiler look at the line once for each member of the Action
union. In this specific situation, that's just twice, but unions can be bigger than two, and the number of cases to analyze grows exponentially in the number of expressions of independent union types. It's just not feasible for the compiler to potentially analyze a line of code a huge number of times. The declined microsoft/TypeScript#25051 was a request to allow developers to ask for such extra analysis on an as-needed basis. But, as I said, it was declined. So there's no simple fix of the form like "just add as if switch(action)
to the end of the line".
In versions of TypeScript before 4.6, your choices here would either be to just give up on type safety by using type assertions or the equivalent:
const runAction = (action: Action) => {
return (handlers[action['@type']] as (action: Action) => number)(action);
}
or to write redundant code to force the compiler to perform the kind of multiple-pass analysis I mentioned before:
const runAction = (action: Action) => {
return action['@type'] === "square" ?
handlers[action['@type']](action) :
handlers[action['@type']](action);
}
Neither of these are great. The former is my general recommendation if you care more about convenience than safety. The latter used to be the only way to get type safety, but it doesn't scale well.
Starting in TypeScript 4.6, microsoft/TypeScript#47109 will be released, which gives a way to refactor code with correlated unions to a form that the compiler can verify as safe. It involves using generics with so-called "distributive object types". Here's how it might look for your example:
type ActionMap = { [A in Sum | Square as A['@type']]: A }
type Action<K extends keyof ActionMap = keyof ActionMap> =
{ [P in K]: ActionMap[P] & { "@type": P } }[K]
const runAction = <K extends keyof ActionMap>(action: Action<K>) => {
return handlers[action['@type']](action)
}
First we set up an ActionMap
type which is a simple mapping from A['@type']
as keys to the corresponding action type. It looks like {sum: Sum; square: Square}
.
Then we define Action
not as a plain union, but as a generic Action<K>
type that takes a subtype of "sum" | "square"
and produces the corresponding action type. This is the "distributive object type" where Action<K>
will be a union if K
is a union. There's a default, so just writing Action
produces the same union type from your code. Note that there's a little twist here, which is that we are explicitly telling the compiler that, generically, for each union member P
in K
, Action<K>
will have a "@type"
property of type P
. This is redundant information, but it helps the compiler see the correlation better.
Finally, runAction()
is now a generic function in K
, where action
is of type Action<K>
. And now, handlers[action['@type']](action)
is seen as type safe without having to be written multiple times. The downside is that the refactoring is not obvious, but this is about as good as it gets in terms of type safety and scalability.
Playground link to code