2

Say that I have a union type Action discriminated on a single field @type, like so:

interface Sum {
    '@type': 'sum'
    a: number
    b: number
}

interface Square {
    '@type': 'square'
    n: number
}

type Action = Sum | Square

Then I write a object containing one method for each Action using key remapping:

const handlers: {[A in Action as A['@type']]: (action: A) => number} = {
    sum: ({a, b}) => a + b,
    square: ({n}) => n ** 2
}

Is there a safe way to call one of the handlers passing in the Action as an argument? This code should be safe:

const runAction = (action: Action) => {
    return handlers[action['@type']](action) // <-- Not valid
}

But TypeScript says:

Argument of type 'Action' is not assignable to parameter of type 'never'.
  The intersection 'Sum & Square' was reduced to 'never' because property ''@type'' has conflicting types in some constituents.
    Type 'Sum' is not assignable to type 'never'.

What would be a good way of writing the above function in a safe way without casting to a less strict type?

Playground link

Henrik Karlsson
  • 5,559
  • 4
  • 25
  • 42
  • 1
    `handlers[action['@type']]` produces union of functions which is almost never desired result. Please see my [article](https://catchts.com/react-props) or [this](https://stackoverflow.com/questions/68966710/how-to-get-concrete-type-from-mapping-variable#answer-68967097) answer for more explanation. TL DR intersection of `Sum & Square` produces `never` – captain-yossarian from Ukraine Feb 22 '22 at 14:49
  • 1
    [This](https://stackoverflow.com/questions/71177030/create-a-union-function-types-in-typescript#comment125819660_71177030) question/answer is also related – captain-yossarian from Ukraine Feb 22 '22 at 14:52
  • 2
    This kind of correlated union type thing is mentioned in [ms/TS#30581](https://github.com/microsoft/TypeScript/issues/30581) and is only supportable in a non-redundant type safe way in TS4.6+ (which is coming out very soon), due to the PR at [ms/TS#47109](https://github.com/microsoft/TypeScript/pull/47109) ... but the fix requires refactoring. It looks something like [this](https://tsplay.dev/mqErRm) for your example. Do you want me to write this up as an answer explaining the situation? – jcalz Feb 22 '22 at 16:53
  • @jcalz Interesting, I'm definitely going to be looking out for the next typescript release. Thanks for knowledge, it's always difficult to google typescript problems due to the many kind of obscure terms. – Henrik Karlsson Feb 23 '22 at 10:45
  • If you wish to add an answer for others who find the question I'll of course accept it! – Henrik Karlsson Feb 23 '22 at 10:46

1 Answers1

1

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

jcalz
  • 264,269
  • 27
  • 359
  • 360