Home > Blockchain >  Typescript: union type convers to never in conditional type
Typescript: union type convers to never in conditional type

Time:08-10

I found an issue which I don't know how to overcome...

I created a simple type to demonstrate the problem:

type SimpleTest<U> = U extends {something: string}
    ? () => void
    : (a: U) => U

And this type does not work as I wanted with union types:

function test(a: SimpleTest<'test' | 'rest'>) {
    a('test');     // Error: TS2345: Argument of type 'string' is not assignable to parameter of type 'never'.
}

There is an error because TypeScript interprets type SimpleTest<'test' | 'rest'> as (a: never) => ("test" | "rest"). But I want it to be (a: "test" | "rest") => ("test" | "rest").

What is really surprising for me that the type of the returning value is correct, but the same type in the function parameter is transformed to never...

I tried some things to find a workaround but without any success...

CodePudding user response:

Your SimpleTest<U> is (accidentally) a distributive conditional type because it is of the form XXX extends YYY ? AAA : BBB where XXX is a generic type parameter. Distributive conditional types distribute over unions in their inputs. So if F<T> is a distributive conditional types, then F<A | B | C> is equivalent to F<A> | F<B> | F<C>: the union input is split into its individual members, the type function is applied to each member, and the result is joined back together in a union.

And so SimpleTest<'test' | 'rest'> is evaluated as SimpleTest<'test'> | SimpleTest<'rest'>:

type Test = SimpleTest<'test' | 'rest'>;
// type Test = ((a: "test") => "test") | ((a: "rest") => "rest")

This is a union of function types, and unions of function types aren't generally easy to call. If I handed you a function and said it's either a function that accepts only "test" or it's a function that accepts only "rest" but I don't know which one, then you couldn't call it safely with either "test" or "rest". That's a general feature of a union of functions; you can only safely pass it an intersection of the parameter types... if you had a value which was both "rest" and "test", then you could call the function. But there are no values like that; the intersection "rest" & "test" is the impossible never type, and that's why you get the error:

a('test'); // error! 'string' is not assignable to 'never'.

When you have a conditional type that's unintentionally distributive, you can make it non-distributive by modifying it so that the checked type is not a generic type parameter itself. The tersest way to do this is to change XXX extends YYY ? AAA : BBB to [XXX] extends [YYY] ? AAA : BBB. Technically you're now comparing two single-element tuple type, which are considered covariant in their elements, so [XXX] extends [YYY] if and only if XXX extends YYY. But since [XXX] isn't a generic type parameter (it's a tuple containing such a type parameter), the conditional type is no longer distributive.

So you can make this change:

type SimpleTest<U> = [U] extends [{ something: string }]
  ? () => void
  : (a: U) => U

And now things behave as expected, and SimpleTest<U> is never a union of functions, no matter what you plug in for U:

type Test = SimpleTest<'test' | 'rest'>;
// type Test = (a: "test" | "rest") => "test" | "rest"

function test(a: SimpleTest<'test' | 'rest'>) {
  a('test');  // okay
}

Playground link to code

  • Related