typescripttypeguardsnullish-coalescing

Why does the nullish coalescing operator not work as a typeguard in typescript?


With Typescript 3.7 the nullish coalescing operator was introduced. It would seem to be the perfect type guard for cases like

const fs = (s: string) => s
const fn = (n: number) => n

let a: string | null | undefined
let b: number | null | undefined

const x = (a ?? null) && fs(a)
const y = (b ?? null) && fn(b)

But if you put that code into typescript playground, it alerts you on both a an b params passed to fs / fn functions like:

Argument of type 'string | null | undefined' is not assignable to parameter fo type 'string' I experimented a bit further and found it is not only an issue isolated to the nullish coalescing operator, but couldn't bend my mind around when typescript is able to use soemthing as a typeguard and when not (below you find some examples)

The two last line were confusing me most. It seems to me both expressions that are assigned to x7 and x8 would be completely equivalent but while in the expression assigned to x8 the typeguard works, it doesn't seem to be ok for typescript in the x7 expression:

const fs = (str: string) => str
const create = (s: string) => s === 's' ? 'string' : s === 'n' ? null : undefined
const a: string | null | undefined = create('s')
const b: string | null | undefined = 's'
let x
if (a !== null && a !== undefined) {
    x = a
} else {
    x = fs(a)
}
const x1 = a !== null && a !== undefined && fs(a)
const x2 = a !== null && a !== void 0 && fs(a)
const x3 = (a ?? null) && fs(a)
const x4 = (b ?? null) && fs(b)
const x5 = a !== null && a !== undefined ? a : fs(a)
const something = a !== null && a !== undefined
const x6 = something ? a : fs(a)
const x7 = something && fs(a)
const x8 = (a !== null && a !== undefined) && fs(a)

I'm not sure, if typescript is just incapable to apply the type guard for some reason or if it is acually a bug in typescript. So is there kind of a rulebook when typescript can apply a typeguard and when not? Or is it maybe a bug? Or is there some other reason I don't get those examples to compile?

Btw. when using a user-defined type guard of course that works perfectly, but it would be nice not to have to add some runtime code for the typeguard to work.


Solution

  • I spent a long time trying to write out the mechanical explanation for why particular expressions like expr1 || expr2 && expr3 act as type guards in certain situations and not in others. It ended up becoming several pages and still didn't account for all the cases in your examples. If you care you can look at the code implemented for expression operators in microsoft/TypeScript#7140.


    A more high-level explanation for why this limitation and ones like it exist: when you, a human being, sees a value of a union type, you can decide to analyze it by imagining what would happen if the value were narrowed to each member of that type, for the entire scope where that value exists. If your code behaves well for each such case analysis, then it behaves well for the full union. This decision is presumably made based on how much you care about the behavior of the code in question, or some other cognitive process that we cannot hope to reproduce by a compiler.

    The compiler could possibly do this analysis all the time, for every possible union-typed expression it encountered. We could call this "automatic distributive control flow analysis" and it would have the benefit of nearly always producing the type guard behavior you want. The drawback is that the compiler would require more memory and time than you'd be willing to spend, and possibly more than humanity is able to spend due to the combinatorial explosion that happens as each additional union-typed expression has a multiplicative effect on required resources. Exponential-time algorithms don't make for good compilers.

    At times I've wanted to be able to hint to the compiler that a particular union-typed value in a particular scope should be analyzed this way, and I even filed a request for such "opt-in distributive control flow analysis", (see microsoft/TypeScript#25051), but even this would require a lot of development effort to implement and would deviate from the TS design goals of enabling JS design patterns without requiring the developer to think too hard about control flow analysis.

    So in the end, what TypeScript language designers do is to implement heuristics that perform such analysis in limited scopes that enable conventional and idiomatic JavaScript coding patterns. If code like (a ?? null) && fs(a) is not considered idiomatic and conventional enough for the language designers (this is partially subjective and partially depending on examining a corpus of real-world code), and if implementing it would result in a major compiler performance penalty, then I wouldn't expect the language to support it.

    Some examples:


    So that's the closest I can get to an authoritative or official answer on this. Hope it helps; good luck!