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

1

10

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.

Wilfredwilfreda answered 14/4, 2020 at 22:21 Comment(6)
I don't see any point to using ?? in your examples. a ?? null is just a in every case except a === undefined, in which case it's null.Bondage
If you were expecting (a ?? null) && fs(a) to mean "null if a is nullish, or fs(a) otherwise", well, that's not what it means.Bondage
You're right, the a ?? null might not make much sense without the original context it is comming from, it is just about narrowing down types and eliminating null, and undefined, but in the end it doesn't make any difference, if you replace it with a ?? fs(a) the ?? operator still doesn't work as a type guard. My point is, when typescript reaches the point where a is passed to fs, I would expect it should be clear, that it can't be null or undefined anymore, right?Wilfredwilfreda
?? fs(a) is even wronger, because then fs(a) is only evaluated if a is null or undefined.Bondage
I don't have an authoritative answer for this yet, but the compiler doesn't distribute control flow analysis over every possible member of every union type, since that would be prohibitively expensive in general. Instead it uses heuristics to enable common programming idioms to act as type guards. I doubt that something like (a ?? null) && fs(a) is "common" enough. Still looking for an "official" answer thoughKoch
@user2357112supportsMonica I get your point, so maybe x3 is just to odd for typescript to apply any type guard, but what about x7 and x8, there I really don't get why typescript is evaluating one type guard but not the otherWilfredwilfreda
K
5

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:

  • UPDATE FOR TS4.4; the following is fixed in microsoft/TypeScript#44370 microsoft/TypeScript#12184: support "saving" the result of a type guard into a constant (like your something example) for later use. This is an open suggestion marked as "revisit" with the ominous proclamation by a language architect that it would be difficult to do it in a performant manner. This might be idiomatic, but it might be hard to implement effectively.

  • microsoft/TypeScript#37258: support successive type guards where narrowings are performed on multiple correlated variables at once. It's closed as too complex because to avoid an exponential-time algorithm you'd need to hardcode it to some small number of checks which wouldn't be very beneficial in general. The suggestion by the language maintainers: use more idiomatic checks.


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

Koch answered 15/4, 2020 at 15:51 Comment(2)
I was hoping not to get such an answer, but it's probably a good one. The real issue behind my question was, that in our company we work with GraphQL and therefore optional properties are always typed as PropertyType | null | undefined which are pretty nasty especially with numbers, as 0 is also interpreted as falsy and the b && typeguard is not working but you need to use the typeof b === 'number' && typeguard which is a bit clumbsy. We were looking for an easier typeguard. But in the end, I'm also not so sure anymore about (b ?? null) is really that much better as it is not so intuitive.Wilfredwilfreda
Thanks for the detailed answer! A followup question. What do you think about a simpler case with no combination of operators? Just nullish but still doesn't guard, like this: tsplay.dev/mLqPvWPogy

© 2022 - 2024 — McMap. All rights reserved.