August 11, 2021
0 strokes bestowed

Discriminated Unions and Destructuring in TypeScript

edit

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.


Finished reading?

Here are a few options for what to do next.

Like
Liked the post? Click the beard up to 50 times to show it
Share
Sharing this post on Twitter & elsewhere is a great way to help me out
Support
Was this post valuable to you? Make a donation to show it
Make a Donation
Kofi logo

Related Post:
Enumerate, Don't Booleanate
Tags
ReactJavaScriptTypeScript

Let's talk some more about JavaScript, React, and software engineering.

I write a newsletter to share my thoughts and the projects I'm working on. I would love for you to join the conversation. You can unsubscribe at any time.

Data Structures and Algorithms Logo
Data Structures and Algorithms

Check out my courses!

Liked the post? You might like my video courses, too. Click the button to view this course or go to Courses for more information.
View on egghead.io
Kyle Shevlin's face, which is mostly a beard with eyes
Kyle Shevlin is a software engineer who specializes in JavaScript, React and front end web development.