Exhaustive Switch Expressions in Typescript
Replo builds agents that run your business - you chat with the agent, and our system renders the conversation in a React tree in front of the user. Avoiding runtime errors in our product is extremely important for us, since runtime errors mean that our customers' chats might fail to render and become non-interactable, which would mean the agent can't do work and the session 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, a ChatPromptSource in Replo (which records where a user's message to the agent came from) 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.
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 content:
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. It's just a lot to have to type (or a lot of tokens for Claude to generate).
We eventually realized we could reduce some boilerplate by using IIFEs:
Close readers might realize that if we were rendering a list of sources inside a map, we could have just returned the result of the switch statement directly. Unfortunately this breaks down when there's only a single ChatPromptSource you're trying to render inline, like above:
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 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 prompt source and message types across our codebase, which means there's less room for errors due to not handling certain types. We've tried to migrate all
ifstatement 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 how we render the title of a mobile panel:
You can also do an exhaustive switch over something other than
type- we usetypeas the default since most discriminated unions in our system use that name, but our chat mentions are discriminated onkind, 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.ChatPromptSourceKey, 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.
