Type-safe filters in TypeScript
The problem
The built in Array.filter
method cannot be used to filter array items in a type safe way. To exclude some items on the type level we would need a typecast. Are there any better alternatives?
Oranges only please
To demonstrate this, let’s say we have a basket of apples and oranges. We want to make some fresh orange juice. And of course all that using TypeScript.
Our first approach might look like this:
1 | // Let's pretend this function is a juice squeezer |
Note that this example uses type literals extensively to make it more concise.
Looks great, however, TypeScript will not allow to use squeezeJuice
like that. Instead, we will get a type error:
1 | const juice = squeezeJuice(oranges); |
Why? basket.filter
filtered apple
values just fine, however this method always returns an array of the same type as its input. So even that apples are filtered on the value level, on the type level exactly the same array type is returned.
A little typecast can “solve” the situation.
1 | const oranges = basket.filter((fruit) => fruit === 'orange') as Array<'orange'>; |
However, typecasts are bad for your code. Eg. in this case, if we used invalid filtering function we wouldn’t known until the program runs. An apple would got through to the squeezer and break the device. No more fresh juice!
How can we improve this situation?
Refining an array
The built-in filter
didn’t suit our needs, so let’s try to implement a type-safe filter ourselves.
Also, let’s use type guards because we want to feel smart today.
1 | const refineArray = <A, B extends A>( |
Wait, but what are type guards?
In short, type guard is a function that can assert if its input argument is of specific type. For example,
(x: unknown): x is number => typeof x === "number"
is a super simple example of a type guard. It returns a boolean (typeof x === "number"
is true or false) and its return type signature containes extra type level information (x is number
) which can be used by the compiler after the function call. In this case if it returnstrue
that means that type ofx
is a number. Please read TypeScript documentation for further details.
Our refineArray
function seems to work, however, note the typeguard1
(fruit): fruit is 'orange' => fruit === 'orange'
is a bit explicit. It requires us to provide the exact type we would like the array to have, without any helpful type inference. So uncivilized.
refineArray
is usable in this simple scenario, however it can get cumbersome to declare its type guard signature with complex or generic types. Also that’s another area for possible human error.
One step back, two steps forward
Let’s try a different approach. Maybe a sibling of filter
function, map
could help us?map
maps returned array on a type level as well, seems promising. So what if instead of filtering we could map values that we don’t want to kind of a no value?
Our first approach could be to use undefined
as a no value:
1 | const oranges = basket.map((fruit) => fruit === 'orange' ? fruit : undefined) |
That leaves us with a bit simpler array but still a heterogeneous one since we’ve introduced a new type undefined
. We could filter this array and typecast the result but this is yet another step back.
So can we use map
without introducing any other types that input arguments?
What about using an empty array to represent no value? To stay consistent a valid result could return an array with a single item.
1 | const oranges = basket.map((fruit) => fruit === 'orange' ? [fruit] : []) |
Almost there. Result is not exactly what we wanted but looks promising. Now we only need a function that would turn an Array<Array<T>>
into an Array<T>
. Meet flat:
1 | const oranges = basket.map((fruit) => fruit === 'orange' ? [fruit] : []).flat(); |
It turns out, this use case of mapping each item into an array and joining results is so common that it has its own built-in method - flatMap. Let’s try it:
1 | const oranges = basket.flatMap((fruit) => fruit === 'orange' ? [fruit] : []); |
Summary
You can use .flatMap
for type safe filtering.
Pros:
- Works great with type inference - no need for typecasts or explicit typings.
- It’s built in, no need for custom code or libraries.
- No space for human error on type level as compared to
.filter
.
Cons:
- It’s a bit less readable than plain
.filter
.
Discussion
If TypeScript was able to infer type of type guard functions, refineArray
would be equally or even more user friendly than flatMap
for the sake of filtering. What is more, then the built-in Array.filter
method could use the same typings as we declared in refineArray
so that we would get a best of all worlds - a built-in, type safe, readable filtering function.
TL;DR: You can use flatMap
as a built-in type-safe method to filter heterogeneous arrays.