Exhaustive Switch Expressions in Typescript
Replo Page Builder, Replo's first product release, is a no-code editor for building Shopify stores - you drag and drop components onto a canvas, and our system renders the components in a React tree on the user's storefront. Avoiding runtime errors in our product is extremely important for us, since runtime errors mean that our customers' pages might fail to hydrate and become non-interactable, which would mean products can't be added to cart and ad spend gets wasted.
As a result, we use Typescript heavily in our codebase - about ~650,000 lines of it. Our data model comes with a lot of domain objects which have different content depending on their type - for example, an Interaction in Replo (equivalent to a click event listener in regular javascript) is represented internally as something like:
Many, many entities throughout our system are modeled like this. Using discriminated unions in Typescript to represent them means that we can write code that's aware of an object's type
property, and based on that type
, can safely render custom content which can safely use its properties.
We omitted the id
property for brevity in this example, but almost all of these entities have an id which is used to uniquely identify them. This is especially useful for React key
s in lists, etc.
In our React components, this has historically looked something like this (which we got from this stackoverflow), where we switch over the type to render different controls:
This makes a lot of sense, but it's a lot of boilerplate to have to write for a pattern which we have to use all the time - every component needs a let
, a switch
, a return
, and a break
for each type, plus the key
s for the div
s. It's just a lot to have to type (or a lot of tokens for Claude to generate).
The key
application doesn't always look like this - you can use fragments to
make it a little nicer, or do other wrapper components, but you'll always have
to type out the key
twice in this example.
We eventually realized we could reduce some boilerplate by using IIFEs:
Close readers might realize that in the first example, since we're inside a map
, we could have just return
ed the result of the switch statement directly. Unfortunately this breaks down when there's only a single Interaction
you're trying to render:
In this example, return
won't work - you'd still need to wrap it in an IIFE. There are several other situations in where return
doesn't work well, though it does for the map
case.
This is a little nicer (no need to have the key
twice, no need for break
and let
), but it's still a switch
and a lot of parentheses to have to type every time, and IIFEs aren't as common as switch statements so it's hard to get AI models to generate them by default.
In order to make this easier, we wrote a short utility which has since become one of the most used utils in our Turborepo monorepo. It's called exhaustiveSwitch
- it's a function which will automatically infer the type of a discriminated union and allow returning a different value in a type-safe way.
In this post we'll explore exhaustiveSwitch
, how it works, and the benefits we've seen from using it in our codebase.
The full code for exhaustiveSwitch is at the bottom of this post. A typescript playground with the full code is here.
#Discriminated Unions
If you're not familiar with discriminated unions in typescript, this reference from the typescript docs is the best intro I've been able to find. tl;dr: if you have a union type in typescript like this:
You'll be able to compare the values such that typescript understands that the type can be either of the values, but only one of them.
This is one of the best applications of typescript's Type Narrowing feature. Narrowing in the right places means you can to less as
casting and use any
and unknown
less.
#Benefits of exhaustiveSwitch
There are a few key benefits we've seen from using exhaustiveSwitch:
-
Conditional React rendering. As described in the example above, conditional rendering in React gets a lot less verbose. You can destructure the conditional values directly, which means less boilerplate to make things work:
-
Exhaustiveness. If you forget a case, typescript will error. This forces us to consider the implications of adding new component and interaction types across our codebase, which means there's less room for errors due to not handling certain types. We've tried to migrate all
if
statement checks for types to exhaustiveSwitch in order to keep this safety working. -
Flexibility. The util has grown to be flexible enough to be ergonomic in most situations. For example, if you just have a single string union and you want to switch over it, you can pass it directly to exhaustiveSwitch and just provide values instead of functions, which is the case with our design system:
You can also do an exhaustive switch over something other than
type
- we usetype
as the default since most discriminated unions in our system use that name, but design system components usevariant
, and it's easy enough to use exhaustiveSwitch there:
#Analyzing the Types
Here's the full code for exhaustiveSwitch (there are a few generics which might make this complicated to read - we'll go through each of them):
The core thing here is StructureRecord
, which is a record which enforces the exhaustive types that we need. There are three generic types which make this up:
DiscriminatedType
, which is the thing we're trying to switch over, e.g.Interaction
Key
, which is the key of the type you want to switch on - by default this is"type"
but the generic version lets you pass in anykeyof typeof DiscriminatedType
, so that you can switch over any property of your discriminated typeReturnType
, which generally typescript will infer — this is the type which will be returned from the call to exhaustiveSwitch
The [SpecificType in DiscriminatedType as SpecificType["type"]]: ReturnType | ((value: SpecificType) => ReturnType)
line is the key - this uses typescript Mapped Types to say “this is a record where every key in DiscriminatedType
must map to either ReturnType
or a function which takes in the value type related to that key, and returns ReturnType
”.
#Opportunities for Improvement
When I wrote this util a few years ago, I really tried to get it so that we could pass both the mapping of types and the actual discriminated type into exhaustiveSwitch
, so that you could do something like exhaustiveSwitch(value, mapping)
instead of having to do exhaustiveSwitch(value)(mapping)
.
So far, I haven't been able to figure out how to make the types work here. If you can, please let me know!
#Going Forward
We're continuing to improve exhaustiveSwitch as more use cases come up. Maybe eventually we'll release it as a package, but we don't feel comfortable doing that yet without giving the broader community some time to use it.
It's also worth noting that you might not need this util. If regular switch statements work for you or your team, our recommendation is to use that! Simpler is better. If you have more complex discriminated unions you need to match against, especially nested ones, using a fully featured library like ts-pattern might be a better fit.
Potentially, exhaustiveSwitch might become obsolete when the ECMAScript Pattern Matching proposal is fully implemented. At that point, we'll be thrilled to remove it from our codebase to update to something which is supported fully in the javascript runtime.
If you'd like to work with our small engineering team on typescript-specific infrastructure like this, we're hiring!
The full code for exhaustiveSwitch is in a typescript playground here.