Search code examples
typescript

Why does TypeScript think `(s: string, n: number) => (s || undefined) && n` can return empty string?


I am writing a function that returns undefined when an input is falsy. In TypeScript 5.6.2, I can't get the following example past the type checker:

// Return type: number | "" | undefined
const f1 = (s: string, n: number) => (s || undefined) && n;

// Type 'number | "" | undefined' is not assignable to type 'number | undefined'.
//   Type 'string' is not assignable to type 'number'.(2322)
const x: number | undefined = f1("a", 1);

Playground

The inferred return type says that f1 could return an empty string. This is annoying because I need to handle a string return type that is impossible. I think the return type should be number | undefined instead.

The same thing happens when the string and number are switched:

// Return type: string | 0 | undefined
const f2 = (s: string, n: number) => (n || undefined) && s;

When I use a ternary operator or coerce to boolean, the return type is inferred correctly:

// Return type: number | undefined
const f3 = (s: string, n: number) => (s ? true : undefined) && n;

// Return type: number | undefined
const f4 = (s: string, n: number) => (!!s || undefined) && n;

Solution

  • TypeScript's type system isn't expressive enough to represent "a truthy string" in a way that has an effect on control flow analysis. Only the empty string of the literal type "" is falsy, so all strings except the empty string are truthy. But in order to say "all strings except the empty string" TypeScript would need something like subtraction types as requested in microsoft/TypeScript#4183 or negated types as requested in microsoft/TypeScript#4916. It has neither of these. So there's no string ~ "" or string & not "".

    If there were such a type, then presumably s || undefined would be of type (string & not "") | undefined (since logical OR (||) evaluates to its first operand if it is truthy, or its second operand otherwise, thus you get the truthy part of the first operand unioned with the second operand). And then (s || undefined) && n would be of type number | ("" & not "") | undefined which would simplify to number | undefined (since logical AND (&&) evaluates to its first operand if it is falsy, or its second operand otherwise, thus you get the falsy part of the first operand unioned with the second operand).

    What actually happens is that s || undefined is of the type string | undefined, and the fact that it cannot be "" is lost. And then (s || undefined) && number evaluates to the falsy part of the first operand (string | undefined) union since string | undefined is falsy if and only if its value is "" | undefined, that's what you get unioned with number. TypeScript loses track of "cannot be """.


    This also explains how 0 shows up when string and number are switched. 0 is the only falsy number, but there is no number ~ 0 or number & not 0 to express truthy numbers.