Discriminated Unions and Destructuring in TypeScript
I’m writing this post because I’ve forgotten the following material enough times to warrant leaving myself a permanent resource for future reference. I figure it will likely help out a few of you as well.
I am a big fan of state machines, but often find myself unable to use them in the projects I work on. People are so hesitant to give them a try. I’ll never understand it. Given this, I turn to the next best thing: enums.
I like to enumerate the finite states my functions can return. If you’re writing TypeScript, something I find myself doing more and more of these days, enumerated states often mean using “discriminated unions”.
A discriminated union is a type where a key or value can be used to determine the rest of the shape of that type. An example might be several types for objects that have a kind
key, like so:
type GetPostsSuccess = {
kind: 'success'
context: {
data: { posts: [] }
}
}
type GetPostsFailure = {
kind: 'failure'
context: {
error: { message: 'something went wrong' }
}
}
I can combine these with a union:
type GetPostResults = GetPostsSuccess | GetPostsFailure
And now I can discriminate that union by the kind
key:
function getPosts(): GetPostResults {
// Let's assume something happens and I get a result
}
function doSomethingWithPosts() {
const result = getPosts()
switch (result.kind) {
case 'success':
return result.context.data.posts
case 'failure':
return result.context.error.message
}
}
I don’t have to check if result.context
has error
or data
in either case because the TypeScript compiler knows that if result.kind
is "success"
, then the rest of the object has context.data.posts
. This is a really nice feature, but it’s easily broken with destructuring.
Changing that example only slightly, we break everything:
function getPosts(): GetPostResults {
// Let's assume something happens and I get a result
}
function doSomethingWithPosts() {
const { kind, context } = getPosts()
switch (kind) {
case 'success':
return context.data.posts // Error
case 'failure':
return context.error.message // Error
}
}
If you try this in TypeScript, which you can do here, it will error. Twice, actually. It will tell you that data
doesn’t exist on context
in the 'success'
case and that error
doesn’t exist in the 'failure'
case. To which you will respond with, “Sure the heck does!”
You, a mere mortal, can determine precisely what shape the type is, but alas, the TypeScript compiler can not.
Why?
In short, TypeScript loses the “refinement” of the discrimination when we use object or array destructuring. If you destructure an object or an array, TypeScript is unable to remember the shape of the whole based on a part. If we destructure kind
, it completely forgets what context
specifically is, and instead types it as a union of what it could be:
type context =
| {
data: { posts: [] }
}
| {
error: { message: 'something went wrong' }
}
It’s created a union that’s no longer discriminated and you’re stuck having to check what keys or what objects again. Not ideal.
I’ll give you another example using an array as a tuple. This is a replication of the very problem I was working on today when I ran into this.
I was trying to set up some state to follow the progress of calling an API. This is a very common pattern in web apps that benefits a lot from using enumerated states. Here are the types I created:
enum States {
IDLE = 'IDLE',
LOADING = 'LOADING',
SUCCESS = 'SUCCESS',
FAILURE = 'FAILURE',
}
type Post = {
title: string
body: string
}
type Data = { posts: Array<Post> }
type Results =
| [States.IDLE, undefined]
| [States.LOADING, undefined]
| [States.SUCCESS, Data]
| [States.FAILURE, Error]
Examining Results
, we can see a discriminated union based on the States
enum. Let’s create a custom hook that will use these types and fetch our posts:
function useGetPosts(): Results {
const [result, setResult] = useState<Results>([States.IDLE, undefined])
useEffect(() => {
setResult([States.LOADING, undefined])
fetch('/api/posts')
.then(res => res.json())
.then(data => {
setResult([States.SUCCESS, data])
})
.catch(error => {
setResult([States.FAILURE, error])
})
}, [])
return result
}
Going through the code, our typechecker is satisfied. Our Results
tuple is always one of our four possible results. Let’s make use of this in a component.
function Posts() {
const result = useGetPosts()
const [state, context] = result
switch (state) {
case States.IDLE:
return <div>idle</div>
case States.LOADING:
return <div>loading...</div>
case States.SUCCESS: {
return (
<div>
{context.posts.map(post => (
<div key={post.title}>
{post.title} {post.body}
</div>
))}
</div>
)
}
case States.FAILURE: {
return <div>{context.message}</div>
}
}
}
What do you think happens in this case? We lose our refinement!
We’ve done a perfectly natural thing in modern JavaScript. We’ve returned a small array from our custom hook with our state
and some relevant context
, and then we destructured that state
and that context
where we make use of that hook so that our code is really easy to read for humans. But it’s not easy for the compiler.
Instead, we need to make adjustments to keep our refinement.
function Posts() {
const result = useGetPosts()
switch (result[0] /* state */) {
case States.IDLE:
return <div>idle</div>
case States.LOADING:
return <div>loading...</div>
case States.SUCCESS: {
// We can destructure here because our `context` is refined to the
// correct shape by the switch statement. We can also skip
// destructuring `state` and rename `context` to `data` in one step
const [, data] = result
return (
<div>
{data.posts.map(post => (
<div key={post.title}>
{post.title} {post.body}
</div>
))}
</div>
)
}
case States.FAILURE: {
const [, error] = result
return <div>{error.message}</div>
}
}
}
This produces no TypeScript errors. I find the code to be ok. I could live with this, but it does not bring me joy.
In this particular situation, it might be best to stick with an object instead of a tuple. Then at least you could have result.state
and result.context
. You would lose the convenience of renaming the values to be contextually appropriate though, eg data
in SUCCESS
and error
in FAILURE
.
Hopefully this has helped you understand discriminated unions a bit better and prevents you from getting stuck on these destructuring gotchas in the future. At the very least, I hope writing this helps me with those things in the future, too.