Search code examples
typescriptgenericstypestype-constraintsconditional-types

TypeScript cannot derive type constraint inside function type


Suppose I have a constrained type function type Foo<T extends string> that only accepts types that extend string. I would expect the following code to compile. Instead, it gives a type error.

type Foo<T extends string> = T;

type UseFoo1<T> = NonNullable<T> extends string
  ? (param: Foo<NonNullable<T>>) => void
                ^^^^^^^^^^^^^^ 
  : () => void;
Type 'NonNullable<T>' does not satisfy the constraint 'string'.
  Type 'T' is not assignable to type 'string'.

Why can't TypeScript derive that NonNullable<T> extends string?


In the following cases, it does work.

type UseFoo2<T> = T extends string
  ? (param: Foo<T>) => void
  : () => void;

type UseFoo3<T> = NonNullable<T> extends string
  ? Foo<NonNullable<T>>
  : never;

Solution

  • In general, there is a trade-off between the usefulness you get by tracking every type fact logically implied by a conditional type check and the usefulness you get by skipping them. Tracking them gives you more information, and skipping them gives you better compiler performance. And tracking can be hard to do correctly, also. When you have WWW extends XXX ? YYY : ZZZ, it's not always correct to propagate the new XXX constraint on the WWW expression all the way down into the YYY type expression, in the face of variance; if WWW appears in a covariant position in YYY then it is fine to propagate the constraint, but if it appears in a contravariant position then it is not always desirable to do this. So I'd say that no matter how the TS team decides to implement such constraint propagation, there will be unmet use cases.

    In particular, it looks like you're seeing a side effect of a bug fix for microsoft/TypeScript#43427, where the following code was giving an error:

    type Q<T> = number extends T ? (n: number) => void : never;
    function fn<T>(arg: Q<T>) {
      arg(10); // error in TS 4.2-, okay in TS 4.3+
    }
    

    The compiler was narrowing number to the intersection number & T, and then complaining that 10 is not obviously assignable to number & T. Because number appears in a contravariant position in (n: number) => void, this narrowing of number made the function type wider and less useful.

    A fix was implemented in microsoft/TypeScript#43439 not to propagate constraints to contravariant positions, with another fix in microsoft/TypeScript#43599 to specifically re-enable this propagation if the checked type is a type parameter (like T and unlike number or NonNullable<T>). This fixes the above error in TypeScript 4.3 and above, but introduces the error you ran into.


    So that's why it's happening. Since type parameters are exempted from this you can possibly work around it by using conditional type inference to copy your type into a new type parameter, and in TS4.7 and up you can even re-constrain the checked type so that the refactoring doesn't actually change your code that much:

    type UseFoo2<T> = NonNullable<T> extends infer NNT extends string ?
      (param: Foo<NNT>) => void : () => void; // okay
    

    This copies NonNullable<T> into the NNT type parameter but we only go into the true branch if NNT is constrained to string. And you can use NNT in any position that needs a type constrained to string.

    Playground link to code