Why does a Typescript type conditional on `T extends undefined`, with T instantiated with `boolean`, resolve T to `never`?
Asked Answered
F

1

17

The following code is trying to define the type of a function that's called with no arguments when its generic argument is undefined, but with 1 argument for any other argument type. (There may well be better ways to accomplish this, which I'd love to see links to in the comments, but the question is about why Typescript works differently than I expected.)

When T extends undefined is false, T appears to turns into never in the else branch, but only inside a function parameter list...

type OptionalArgBroken<Arg> = Arg extends undefined ?
  () => void :
  (arg: Arg) => void;

const suppressArgBroken:OptionalArgBroken<undefined> = function() { };
suppressArgBroken(); // Fine

const haveArgBroken:OptionalArgBroken<boolean> = function(b:boolean) { };
haveArgBroken(true); // Type error

As you can see on the Playground, the last line above gives the type error

Argument of type 'true' is not assignable to parameter of type 'never'.(2345)

After reading https://github.com/microsoft/TypeScript/issues/31751, I tried wrapping Arg and undefined in []s, and that appears to have fixed the problem:

type OptionalArgWorks<Arg> = [Arg] extends [undefined] ?
  () => void :
  (arg: Arg) => void;

const suppressArgWorks:OptionalArgWorks<undefined> = function() { };
suppressArgWorks(); // Fine

const haveArgWorks:OptionalArgWorks<boolean> = function(b:boolean) { };
haveArgWorks(true); // Fine

Even though that fix worked, this is not the same problem:

type MakesSenseT = undefined extends undefined ? 'yes' : 'no'
const MakesSense:MakesSenseT = 'yes';

type ExtendsUndefined<T> = T extends undefined ? 'yes' : 'no'

const MakesSenseToo : ExtendsUndefined<undefined> = 'yes';
const MakesSenseThree : ExtendsUndefined<boolean> = 'no';

Why did my original code not work?

Typescript Playground Link for the above code

Fleecy answered 21/5, 2020 at 3:30 Comment(0)
A
38

As written,

type OptionalArgBroken<Arg> = Arg extends undefined ?
    () => void :
    (arg: Arg) => void;

is a distributive conditional type because the type being checked, Arg, is a naked generic type parameter.

"Distributive" means that if the Arg passed in is a union, then the type will be evaluated for each member of the union separately and then united back together (so the operation is distributed across the union). In other words, OptionalArgBroken<A | B | C> will be the same as OptionalArgBroken<A> | OptionalArgBroken<B> | OptionalArgBroken<C>.

This is likely not your intent, as evidenced by the fact that you are happy with the results when you wrap your check in [] (which makes the checked type no longer "naked" by "clothing" it).


Furthermore, the TypeScript compiler treats the boolean type as a shorthand for the union of true and false, the so-called boolean literal types:

type Bool = true | false;
// type Bool = boolean

If you hover over Bool in your IDE with IntelliSense, you will see that Bool above is displayed as boolean.

This might be surprising if you think of boolean as a single type and not a union of two other types. And one place this shows up is when you pass boolean to a distributive conditional type: OptionalArgBroken<boolean> is OptionalArgBroken<true | false> which is OptionalArgBroken<true> | OptionalArgBroken<false> which is

type OABBool = OptionalArgBroken<boolean>;
// type OABBool = ((arg: false) => void) | ((arg: true) => void)

You passed in what you thought was a single type and got a union of function types out. Oops. (See microsoft/TypeScript#37279)


And a union of function types can only be safely called with an intersection of their parameters. Read the TS3.3 release notes on support for calling a union of functions for information about why this is.

But that means a value of type OptionalArgBroken<boolean> can only be called with an argument of type true & false, which is reduced to never (see microsoft/TypeScript#31838) because there is no value which is both true and false.

And therefore, when you try to call haveArgBroken, it expects the parameter passed in to be of type never:

const haveArgBroken: OptionalArgBroken<boolean> = function (b: boolean) { };
// haveArgBroken(arg: never): void

And true is not of type never, so it fails:

haveArgBroken(true); // Type error

And that's why your original code did not work.


Note that the same thing happens with

type ExtendsUndefined<T> = T extends undefined ? 'yes' : 'no'

but it is benign because ExtendsUndefined<boolean> becomes ExtendsUndefined<true> | ExtendsUndefined<false> which is 'no' | 'no' which is reduced to just 'no'. It happens to be what you want, but only because there's no way to distinguish the 'no' that came from true with the one that came from false.


Playground link to code

Amando answered 21/5, 2020 at 4:15 Comment(2)
So what's the solution here?Thagard
Woops read question again. TLDR add brackets type ExtendsNever<T> = [T] extends [never] ? 'yes' : 'no'Thagard

© 2022 - 2024 — McMap. All rights reserved.