Happy Employees == Happy ClientsCAREERS AT DEPT®
DEPT® Engineering BlogPlatforms

Union and Intersection Typeguards in TS

If you’ve ever been interested in complex typings in TypeScript, particularly when it comes to typeguards, this is the article for you!

If you’ve ever been interested in complex typings in TypeScript, particularly when it comes to typeguards, this is the article for you! At BYTE/DEPT®, we’ve been developing our open-source in-house typeguards library for TypeScript (found here) for a few years, and we’ve recently dedicated some time to cleaning up some of the more complex typeguards that we use (for intersections and unions of types). This article explains some of the techniques that we’ve used, in the hope that understanding these TypeScript patterns in practice might assist with your TypeScript requirements!

Having started my development journey with PHP, Ken Kantzer’s recent article on security struck a chord with me, particularly where he talks about trusted and un-trusted data at runtime boundaries. Since moving to the TypeScript ecosystem, I’ve loved the approach adopted at BYTE/DEPT® to have type validations at all runtime boundaries, much like those described in this article from 2018. Not only does this make everything much more secure, it also ensures that you can leverage all of the best parts of TypeScript’s type-checking inside your application!

Typeguards

Because TypeScript doesn’t have runtime type-checking, only compile time, there is no way to inherently ensure that data that comes from a ‘dirty’ source (API, Database etc) is structured as expected just by writing typed functions. This can lead to dreaded TypeErrors at runtime. As a partial solution, TypeScript allows us to write so-called "type predicates": functions that have the signature x is y, which allow us to indicate to the compiler that "if an input x returns true in this function, it is of type y". This is very powerful, because it allows us to write tightly-typed code, and be confident that at runtime, there will be no TypeErrors, even when we are dealing with data from a ‘dirty’ source.

Guards in Action at BYTE/DEPT®

In our library, there is a generic Is function type that takes the following form:

type Is<A> = (a: unknown) => a is A

This allows me to write composable typeguards of the form

const isString: Is<string> = (u: unknown): u is string => typeof u === 'string'
const isNumber: Is<number> = (u: unknown): u is number => typeof u === 'number'

There are also similar typeguards for records, structs, arrays and so on. For example, the array one curries a typeguard for the elements, and returns a type predicate.

const isArray = <A>(isa: Is<A>) => (u: unknown): u is A[] => Array.isArray(u) && u.every(isa)

For example:

const isArrayOfStrings: Is<Array<string>> = isArray(isString)

The guard used for objects similarly curries a definition, though the underlying JavaScript means that the function is a lot less clean!

export const isStruct = <O extends { [key: string]: unknown }>(isas: { [K in keyof O]: Is<O[K]> }): Is<O> => (
  o
): o is O => {
  if (o === null || typeof o !== 'object') return false
  const a = o as any
  for (const k of Object.getOwnPropertyNames(isas)) {
    if (!isas[k](a[k])){
      return false
    }
  }
  return true
}

For example:

const isFoo: Is<{foo: string}> = isStruct({foo: isString})

This works well for basic types, when you know the structure that an object will take, but sometimes there is ambiguity: you might want to create a typeguard for an array that takes strings, numbers or booleans, or even a typeguard for a type that is made up of an overlap of several complex objects. How do we write a typeguard for that? What form does the type predicate take?

Union Typeguards: A OR B

Until recently, we had a very basic overloaded isUnion function:

export function isUnion<A, B>(isA: Is<A>, isB: Is<B>): (u: unknown) => u is A | B
export function isUnion<A, B, C>(isA: Is<A>, isB: Is<B>, isC: Is<C>): (u: unknown) => u is A | B | C
export function isUnion<A, B, C, D>(isA: Is<A>, isB: Is<B>, isC: Is<C>, isD: Is<D>): (u: unknown) => u is A | B | C | D
export function isUnion<A, B, C, D, E>(
  isA: Is<A>,
  isB: Is<B>,
  isC: Is<C>,
  isD: Is<D>,
  isE: Is<E>
): (u: unknown) => u is A | B | C | D | E
export function isUnion<A, B, C, D, E, F>(
  isA: Is<A>,
  isB: Is<B>,
  isC: Is<C>,
  isD: Is<D>,
  isE: Is<E>,
  isF: Is<F>
): (u: unknown) => u is A | B | C | D | E | F
export function isUnion<A, B, C, D, E, F>(isA: Is<A>, isB: Is<B>, isC?: Is<C>, isD?: Is<D>, isE?: Is<E>, isF?: Is<F>) {
  return (u: unknown): u is A | B | C | D | E | F =>
    isA(u) || isB(u) || (isC && isC(u)) || (isD && isD(u)) || (isE && isE(u)) || (isF && isF(u)) || false
}

This allowed us to write something like:

const isNumberOrString: Is<number | string> = isUnion(isNumber, isString)

From the function signature it quickly becomes apparent that this falls down eventually: if you wanted to have more than six typeguards added to this, you needed to nest isUnion typeguards, which wasn’t great from a code smell point of view, nor for debugging stack traces. But how could you write a type predicate without knowing how many types you were predicating?!

The answer came to one of our engineers while fixing another problem, and we are indebted to the legendary jcalz on StackOverflow, for his exceptionally slick answers to a myriad of TypeScript questions that have been asked over the years. The Typescript community really is great! The answer we came up with was this:

type UnionTupleElements<T extends any[]> = T extends Array<Is<infer U>> ? U : never

export function isUnion<T extends any[]>(
  ...allTypeGuards: T) {
  return (u: unknown): u is UnionTupleElements<T> => allTypeGuards.some((isT) => isT(u))
}

Let’s break this down. What we are doing here is making TypeScript, at compile time, create a type that is the union of the provided types, which we can then use in the predicate. How do we do this?
UnionTupleElements, and in particular the infer U part, is basically saying: assume that there was a type for which any of the provided typeguards would be typeguards, what would the definition of that type be? If there is an answer, then return it. If there is not, return never. Now that we have that type, we can use it in a type predicate. Note that this is a slightly more secure version of u is T[number], since you are using TypeScript to extract the types themselves from the array of typeguards, and so if there were a scenario where there could by definition be no overlap, an never will be thrown at compile time.

Intersection Types: A AND B

It gets much harder when we’re thinking about an intersection. We started with a similarly blunt tool that would allow us to create a typeguard where an input would have to match all of the guards in order to satisfy the type predicate:

export function isIntersection<A, B>(isA: Is<A>, isB: Is<B>): (u: unknown) => u is A & B
export function isIntersection<A, B, C>(isA: Is<A>, isB: Is<B>, isC: Is<C>): (u: unknown) => u is A & B & C
export function isIntersection<A, B, C>(isA: Is<A>, isB: Is<B>, isC?: Is<C>) {
  return (u: unknown): u is A & B & C => isA(u) && isB(u) && (!isC || isC(u))
}

For example:

const isFooAndBar: Is<{foo: string} & {bar: string}> = isIntersection(isStruct({foo: isString}), isStruct({bar: isString}))

But again, this maxed out at a set of three typeguards. After a long time of fiddling with tuple manipulation, we came up with this:

type IntersectTupleElements<T extends any[]> = { [I in keyof T]: (x: T[I]) => void }[number] extends (x: infer I) => void ? I : never;

export function isIntersection<T extends any[]>(...args: { [I in keyof T]: Is<T[I]> }): Is<IntersectTupleElements<T>> {
  return (u: unknown): u is IntersectTupleElements<T> => args.every(isX => isX(u))
}

Let’s break this one down: we are basically asking Typescript to take the array of Typeguards, derive the type for which they are guarding (<T extends any[]>(...args: { [I in keyof T]: Is<T[I]> })) and treat that as a tuple, T. From there, we can use the combination of distributive conditional types and inference from conditional types to reverse engineer the type for the predicate from that Tuple. I like to think of it as asking TypeScript to do a thought experiment: You have this collection of types, imagine that there was a hypothetical type that was all of these types? What would that type look like?
To do this we make a function (x: I) => void, and infer the type I as the type with which all of the types in the tuple T intersect (and hence will pass the typeguards). The TS documentation puts it as:

“multiple candidates for the same type variable in contra-variant positions causes an intersection type to be inferred”.

Without going into too much detail about what covariance and contravariance are (though I love this explanation!), it’s basically saying that by reversing engineering “what would the type be that would satisfy all of these guards?” from “does this variable satisfy all of these guards?”, we can get the intersection type, and then use that in our definition. If, as with the union type, there is no type that could satisfy such a definition (for example string & number), then it will return never, which is helpful.

Conclusion

It’s fair to say that if you’re not familiar with TypeScript, this might take some wrapping your head around. In fact, even if you’re intimately familiar with TypeScript, this might take some wrapping your head around! Even so, understanding why these signatures work is a great exercise in both CS and TypeScript, and has been a great levelling-up journey for me!