Union types are powerful yet often overlooked. At work, I’ve been using Flow which thankfully supports union types. But as I’ve refactored more of our code to use union types, I’ve noticed that our bundle size has been steadily increasing!
In this post, we’re going to explore why that’s the case. We’ll start with a problem which union types can solve, flesh out the problem to motivate why union types are definitely the solution, then examine the resulting cost of introducing them. In the end, we’ll compare Flow to other compile-to-JS languages on the basis of how they represent union types in the compiled output. I’m especially excited about Reason, so we’ll talk about it the most.
Setup: Union Types in a React Component
Let’s consider we’re writing a simple React 2FA1 modal. We’ll be using Flow, but you can pretend it’s TypeScript if you want. The mockup we were given looks like this:
In this mockup:
- There’s a loading state while we send the text message.
- We’ll show an input for the code after the message is sent.
- There’s no failure screen (it hasn’t been drawn up yet).
We’ll need some way for our component to know which of the three screens is visible. Let’s use a union type in Flow:
Union types are a perfect fit! 🎉 Union types document intent and can help guard against mistakes. Fellow developers and our compiler can know “these are all the cases.” In particular, Flow can warn us when we’ve forgotten a case.
Our initial implementation is working great. After sharing it with the team, someone suggests adding a “cancel” button in the top corner. It doesn’t make sense to cancel when the flow has already succeeded, so we’ll exclude it from the last screen:
No problem: let’s write a function called
determine if we need to put a cancel button in the header of a
Short and sweet. 👌 Everything seems to be working great, until…
switch: Optimizing for Exhaustiveness
The next day, we get some updated mocks from the design team. This time, they’ve also drawn up a “failure” screen for when the customer has entered the wrong code too many times:
We can handle this—we’ll just add a case to our
But now there’s a bug in our
needsCancelButton function. 😧 We
should only show a close button on screens where it makes sense, and
'FailureScreen' is not one of those screens. Our first reaction after
discovering the bug would be to just blacklist
But we can do better than just fixing the current bug. We should write code so that when we add a new case to a union type, our type checker alerts us before a future bug even happens. What if instead of a silent bug, we got this cheery message from our type checker?
Hey, you forgot to add a case to
needsCancelButtonfor the new screen you added. 🙂
— your friendly, neighborhood type checker
Let’s go back and rewrite
needsCancelButton so that it will tell
us this when adding new cases. We’ll use a
switch statement with
something special in the
Now Flow is smart enough to give us an error! Making our code safer, one
switch statement at a time. 😅 Union types in Flow are a powerful way
to use types to guarantee correctness. But to get the most out of union
types, always2 access them through a
Every time we use a union type without an exhaustive switch statement,
we make it harder for Flow to tell us where we’ve missed something.
Correctness, but at what cost?
You might not have noticed, but we paid a subtle cost in rewriting
needsCancelButton function. Let’s compare our two functions:
With just an equality check, our function was small: 62 bytes minified.
But when we refactored to use a
switch statement, its size shot up to
240 bytes! That’s a 4x increase, just to get exhaustiveness. Admittedly,
needsCancelButton is a bit of a pathological case. But in general: as
we make our code bases more safe using Flow’s union types of string
literals, our bundle size bloats!
Types and Optimizing Compilers
One of the many overlooked promises of types is the claim that by writing our code with higher-level abstractions, we give more information to the compiler. The compiler can then generate code that captures our original intent, but as efficiently as possible.
To see what I mean, let’s re-implement our
Screen type and
needsCancelButton function, this time in Reason:
biggest difference is that the
case keyword was replaced with the
character. Making the way we define and use union types look the same is
a subtle reminder to always pair union types with
4 Another difference: Reason handles exhaustiveness checking
out of the box. 🙂
What does the Reason output look like?
Not bad! Telling Reason that our function was exhaustive let it optimize
switch statement back down to a single
if statement. In
fact, it gets even better: when we run this through
removes the redundant
Wow! This is actually better than our initial, hand-written
statement. Reason compiled what used to be a string literal
'SuccessScreen' to just the number
2. Reason can do this safely
because custom-defined types in Reason aren’t strings, so it doesn’t
matter if the names get mangled.
Taking a step back, Reason’s type system delivered on the promise of types in a way Flow couldn’t:
- We wrote high-level, expressive code.
- The type checker gave us strong guarantees about our code’s correctness via exhaustiveness.
- The compiler translated that all to tiny, performant output.
I’m really excited about Reason. 😄 It has a delightful type system and is backed by a decades-old optimizing compiler tool chain. I’d love to see more people take advantage of improvements in type systems to write better code!
Appendix: Other Compile-to-JS Runtimes
- union types (identical to the Flow unions that we’ve been talking about),
enums, which are sort of like definition a group of variable constants all at once, and
const enums which are like
enums except that they’re represented more succinctly in the compiled output.
TypeScript’s union type over string literals are represented the same way as Flow, so I’m going to skip (1) and focus instead on (2) and (3).
const enum are subtly different. Not having
used the language much, I’ll refer you to the TypeScript
documentation to learn more about the differences. But for
const enums compile much better than normal
Here’s what normal
enums look like in TypeScript—they’re even
worse than unions of string literals:
So for normal
- It’s not smart enough to optimize away the
And then here’s what
const enums look like—you can see that
TypeScript represents them under the hood without any sort of
- It uses numbers instead of strings.
- It still uses a switch statement, instead of reducing to just an
PureScript is another high-level language like Reason. Both Reason and PureScript have data types where we can define unions with custom constructor names. Despite that, PureScript’s generated code is significantly worse than Reason’s.
- It’s generating ES5 classes for each data constructor.
- It compiles pattern matching to a series of
- Even though it knows the match is exhaustive, it still emits a
throwstatement in case the pattern match fails!
Admittedly, I didn’t try that hard to turn on optimizations in the
compiler. Maybe there’s a flag I can pass to get this
Error to go
away. But that’s pretty disappointing, compared to how small Reason’s
generated code was!
I list Elm in the same class as Reason and PureScript. Like the other two, it lets us define custom data types, and will automatically warn when us pattern matches aren’t exhaustive. Here’s the code Elm generates:
- It’s using string literals, much like Flow and TypeScript.
- It’s smart enough to collapse the last case to just use
default(at least it doesn’t
- The variable names are long, but these would still minify well.
It’s interesting to see that even though Reason, PureScript, and Elm all have ML-style datatypes, Reason is the only one that uses an integer representation for the constructor tags.
2FA: two-factor authentication↩
“Always” is a very strong statement. Please use your best judgement. But know that if you’re not using a
switch, you’re trading off the burden of exhaustiveness & correctness from the type checker to the programmer!↩
More than being a nice reminder, it makes it easy to copy / paste our type definition as boilerplate to start writing a new function!↩