Tagged Unions and Type Guards in TypeScript
This article is a continuation of the series of articles on tagged unions in TypeScript. If you haven’t read it yet, you can access the first article here.
Even thought most of the benefits stemming from using tagged unions are immediately available to developers because the TypeScript compiler understands them on the intrinsic level, sometimes we need to help it out to follow through on our intentions regarding structural transformations.
Filtering arrays
For the purpose of the following example, let’s define some structures that describe different types of users:
type AdminUser = {
type: 'ADMIN';
};type NonAdminUser = {
type: 'NON_ADMIN';
};type User = AdminUser | NonAdminUser;
It should be relatively easy to define a function that filters out non-admin users (or filters in admins), right? Let’s try to write it then:
const filterAdmins = (
users: ReadonlyArray<User>
): ReadonlyArray<AdminUser> =>
users.filter(({ type }) => type === 'ADMIN');
Unfortunately, it didn’t work as we expected. The type of the array after using .filter
is still the original type instead of the planned narrowed one. It appears that TypeScript doesn’t infer anything from the filter
function’s callback. For that to happen, we need to use a type guard.
Type Guards
What is a type guard in TypeScript? In plain words, it’s a function that accepts one and only one argument of type A
and returns a boolean value, describing if the aforementioned argument of type A
is as well of type B
. One-argument functions that return boolean values are formally called predicate functions or simply predicates.
Let’s write one that satisfy our needs:
const isAdminUser = (user: User): user is AdminUser =>
user.type === 'ADMIN';
The only shocking part here might be the substitution of boolean
with user is AdminUser
, which, in the end, is also a boolean statement nevertheless.
We can now use this new function in the filterAdmins
function:
const filterAdmins = (
users: ReadonlyArray<User>
): ReadonlyArray<AdminUser> =>
users.filter(isAdminUser);
Now the TypeScript compiler reports no errors, as it understands the type narrowing hidden in the isAdminUser
predicate.
Type Guards and Generic Tagged Unions
It is possible to implement type guards for generic tagged unions, as visible in the following example — let’s define some containers first for data requiring privileged and unprivileged access:
type PrivilegedContainer<T> = {
type: 'PRIVILEGED';
privilegedData: T;
};type UnprivilegedContainer<T> = {
type: 'UNPRIVILEGED';
unprivilegedData: T;
};type Container<T> =
| PrivilegedContainer<T>
| UnprivilegedContainer<T>;
Similarly to what we have seen before, we can define a generic type guard isPrivilegedContainer
with a type parameter T
:
const isPrivilegedContainer = <T>(
container: Container<T>
): container is PrivilegedContainer<T> =>
container.type === 'PRIVILEGED';
Now, we can write a function that returns a collection of privileged data:
const getPrivilegedData = <T>(
containers: ReadonlyArray<Container<T>>
): ReadonlyArray<T> =>
containers
.filter(isPrivilegedContainer)
.map(({ privilegedData }) => privilegedData);
Caveats
Since type guards are a way of providing hints to the compiler about types, we can leverage it in wrong ways too, giving the compiler a wrong piece of information. For instance, we can misdefine theisAdminUser
function in the following fashion and the TSC won’t report any problems:
const isAdminUser = (user: User): user is AdminUser =>
user.type === 'NON_ADMIN';
This might happen because of copy-pasting, that’s why it is crucial that we test our code before it is shipped to a production system.
Summary
The TypeScript Compiler is definitely smart in terms of inferring types but it’s not an omnipotent tool and sometimes we need to help it by providing some type hints. We can use type guards to inform the compiler about some operations that might narrow types. Type guards can be used as well to narrow generic types.
I presented one of the obvious usages which is leveraging type narrowing against collections of tagged unions. It should be clear though that type guards are a powerful tool that, if used improperly, might introduce a lot of subtle hard-to-find type errors.