@radekmie

On Automatic Type Refinements

By Radosław Miernik · Published on · Comment on Reddit

Table of contents

Intro

Just two months ago, in “On Type Inference”, I explained why I prefer relying on implicit (inferred) types over the explicit ones as much as possible. Just a few weeks later I realized there’s one more thing: type refinements1.

Even today, I heard that “Array#filter is not type-safe” in a context of it not “removing” (refining!) undefined when used as .filter(Boolean). If you think that’s the case, then this text is for you!

Obvious is safe

If you’re using TypeScript 5.5 or newer, you can happily benefit from a trivial yet surprisingly impactful pull request: microsoft/TypeScript#57465. Consider the following code:

function foo(xs: (number | undefined)[]) {
    return xs.filter(x => x !== undefined);
}

function bar(xs: (number | undefined)[]) {
    return xs.filter(x => typeof x === 'number');
}

Before TypeScript 5.5, both foo and bar returned (number | undefined)[]. It was the only “reasonable” outcome, as the inner functions were inferred to (value: number | undefined) => unknown2. So what has changed? Well, it’s not magic – simply a different type is inferred!

function fooInner(x: number | undefined) {
  return x !== undefined;
}

function barInner(x: number | undefined) {
  return typeof x === 'number';
}

Since TypeScript 5.5, both fooInner and barInner return x is number and not plain old boolean. If you never saw such a type, don’t worry, it’s not that complex. In short, a function returning it is called a type predicate and means “it’ll return a boolean; if it’s true, then x should be narrowed to number”.

Was that even possible before 5.5? Yes, you could have used this type explicitly for quite a while now3. And yes, it was quite popular in the community; at least among the ones who liked the “Parse, don’t validate” by Alexis King.

Safe is not necessarily obvious

What if your code is safe but TypeScript is not “smart” enough to see it? Let’s take another example of an almost equivalent4 function:

export function qux(xs: (number | undefined)[]) {
  return xs.filter(x => !!x);
}

From the runtime perspective, all undefineds are removed. But TypeScript does not infer it in the same way as the two functions we had before. Funnily enough, it does work, if we’d use some other type, e.g., Date or any object type. Based on my experiments, it works for most types, including boolean, which is refined to true; it does not work for number or string, though.

Now, for the eager readers: yes, x => !!x is technically the same as Boolean. Although the latter can be used as a constructor (please don’t do that), it does the same when used in .filter(Boolean). But why would one do that? It may be slightly faster, as it does not allocate a new function. To sum up: while it may not be immediately obvious for the reader, it is safe.

Closing thoughts

There are a few open issues that will make more “safe” code “obvious” to TypeScript. These include #15048 (one-sided type guards), #16655 + #50387 (making Boolean work as a type predicate), and #42384 (propagating field-level refinement to the parent). I’m glad that the entire TypeScript team is so heavily focused on developer’s experience.

Main takeaway? Keep your code obvious, it pays off. Duh.

1

TypeScript documentation calls it type narrowing. However, I noticed it’s used interchangeably in the community, and as that’s the name I got used to at the university, I’ll stick to it. Side note: Flow also uses it.

2

The actual type is (value: number | undefined, index: number, array: (number | undefined)[]) => unknown, but for the sake of simplicity, we only care about the first argument.

3

I couldn’t find when were they added exactly, but the release notes of version 3.0 already mention it.

4

Almost, because 0 is falsy, so !!0 returns false, and as such, it is filtered out from the array.