Search code examples
typescripttypescript-genericstypescript-types

Typescript: discriminate generic union in conditional type


Take the following snippet of code:

type A = {a: unknown}
type TakesA<T extends A> = unknown

type B = { b: unknown }
type TakesB<T extends B> = unknown

type AB = A | B
type TakesAB<T extends AB> = T extends A ? TakesA<T> : TakesB<T>

I get the error Type 'T' does not satisfy the constraint 'B'. Type 'AB' is not assignable to type 'B'. However, shouldn't the conditional type have narrowed down the type of T to B in the else-clause? Why does this behaviour occur, and how should I work around it?


Solution

  • Here's another — more simplified — example of the same issue:

    TS Playground

    type Example<T extends string | number> = T extends number
      ? T['toFixed']
      : T['length']; /*
        ~~~~~~~~~~~
    Type '"length"' cannot be used to index type 'T'.(2536) */
    
    

    We expect TypeScript to narrow T to string in the false/else branch, but that does not happen (as of TS version 4.9.3 at least).


    This is explained in the GitHub issue at microsoft/TypeScript#29188:

    jack-williams commented on Jan 3, 2019:


    Conditional types do not produce substitution types for the false branch of the conditional (a.k.a do not narrow in the false branch).

    There was an attempted fix here: #24821, however this was closed.

    ...

    weswigham commented on Jan 3, 2019:


    @jack-williams had identified the core of the issue. We use "substitution" types internally to track the constraints applied to a type within the true branch of a conditional, however we do no such tracking for the false branch. This means that you can't actually bisect a union type with a conditional right now, as @lodo1995 points out, you must chain two conditions and invert the check so your logic is in the true branch instead.

    Part of the reason why we didn't move forward with #29011 (other than one of the relations I identified not holding up under scrutiny) is that tracking falsified constraints with substitution types kinda works... but when you perform the substitution, the information is lost, since we do not currently have a concept of a "negated" type (I mitigated this a little bit by remateriaizling the substitutions that tracked negative constraints late, but that's a bit of a hack). We cannot say that the given T extends string ? "ok" : T that the type of T in the false branch is a T & ~string , for example - we do not have the appropriate type constructors currently.

    We regularly bring up how we really do probably need it for completeness, but the complexity "negated" types bring is... large? At least that's what we seem to think - it's not immediately obvious that a ~string is an alias for "any type except those which are or extend string", and therefore that a ~string & ~number is "any type except strings or numbers" (note how despite the use of &, the english you read as used the word "or").

    So we're very aware of what needs to be done to make this work better... we're just having trouble convincing ourselves that it's "worth it".


    So — in summary — the advice from Dimava is sound, you should use this type, with an unconditional never result for the final "unreachable" branch:

    type TakesAB<T extends AB> = T extends A
      ? TakesA<T>
      : T extends B
        ? TakesB<T>
        : never;
    

    TS Playground