January 21, 2024

Wrangling Tuple Types

edit

This is going to be a short and simple TypeScript tip for you about tuples.

At the time of this writing, JavaScript and TypeScript do not officially have a tuple type. That TC39 proposal is still in the works. The best you can do is a fixed-length array.

So let’s say we have a function that returns a tuple, like a custom hook:

function useBool(initialValue = false) {
  const [state, setState] = React.useState(initialValue)

  const handlers = React.useMemo(
    () => ({
      on: () => setState(true),
      off: () => setState(false),
      toggle: () => setState(s => !s),
      reset: () => setState(initialValue),
    }),
    [initialValue],
  )

  return [state, handlers]
}

const result = useBool()

Returning a tuple is a nice API because it allows the consumer of the result to array destructure the values into custom names whenever it’s used. It’s a bit nicer doing:

const [isOpen, setIsOpen] = useBool()

Than it is to do the object equivalent:

const { state: isOpen, handlers: setIsOpen } = useBool()

That said, what do you think the return type is for our useBool function? The answer might surprise you. result’s type is:

const result: (
  | boolean
  | {
      on: () => void
      off: () => void
      toggle: () => void
      reset: () => void
    }
)[]

The return type is a non-fixed-length heterogenous array of the union of boolean and the shape of our handlers object. Basically, TypeScript doesn’t know the index of our values in the array. We will run into a lot of annoying type issues if we try and use this in a component.

function Secret({ message }: { message: string }) {
  const [isOpen, setIsOpen] = useBool()

  return (
    <div>
      {/**
       * TS will error here, saying you can't call
       * a method on a boolean
       */}
      <button type="button" onClick={setIsOpen.toggle}>
        {/**
         * TS won't yell at you because anything can be truthy
         * or falsy, but it won't know that `isOpen` is strictly
         * a boolean
         */}
        {isOpen ? 'Hide' : 'Reveal'}
      </button>

      {isOpen && <div>{message}</div>}
    </div>
  )
}

The first error that pops out is setIsOpen.toggle might not be a function because it might be a boolean. If you continue to examine isOpen closer, you’ll also find it’s not typed as a boolean, but rather as the union. You can take a look for yourself in this TypeScript Playground.

You and I know that useBool returns a tuple, how do we convince TypeScript of that fact?

The way I used to do it

I advise others to avoid defining function return types whenever possible to allow TypeScript inference to do its thing. There’s really no need to be overly prescriptive. Type the inputs, let TS handle the outputs.

Except in the case of tuples.

As we’ve seen, TypeScript thinks the return type is an array and this is understandable because we don’t have real tuples. So taking our useBool function, we could add a return type to it.

function useBool(initialValue = false): [
  boolean,
  {
    on: () => void
    off: () => void
    toggle: () => void
    reset: () => void
  },
] {
  const [state, setState] = React.useState(initialValue)

  const handlers = React.useMemo(
    () => ({
      on: () => setState(true),
      off: () => setState(false),
      toggle: () => setState(s => !s),
      reset: () => setState(initialValue),
    }),
    [initialValue],
  )

  return [state, handlers]
}

Now, TypeScript knows that we intend to return a fixed length array and forces us to conform to it. The type checker correctly types state and handlers throughout our code.

But our solution is a bit verbose and brittle. If we ever make a change to our hook’s API, we’ll have to manually update the types. Is there a more elegant way? Yes.

Why didn’t I think of this sooner?

A simpler answer to our problem is to add as const after our returned tuple, like so:

function useBool(initialValue = false) {
  const [state, setState] = React.useState(initialValue)

  const handlers = React.useMemo(
    () => ({
      on: () => setState(true),
      off: () => setState(false),
      toggle: () => setState(s => !s),
      reset: () => setState(initialValue),
    }),
    [initialValue],
  )

  return [state, handlers] as const
}

This tells TypeScript that the return value of useBool is a readonly array. It will never be modified. Because it’s readonly, TypeScript correctly types state and handlers anywhere they are used.

It’s a dead simple change, and only recently did it occur to me to do it this way. I should have been doing it this way for years. But hopefully I saved you a little trouble learning it the hard way.

Summary

Unless you tell TypeScript otherwise, it will assume that you’re returning an array, not a tuple, from a function. You can either explicitly define a return type, or use as const on the return value to inform TypeScript of the correct types.


Liked the post?
Give the author a dopamine boost with a few "beard strokes". Click the beard up to 50 times to show your appreciation.
Want to read more?
Related Post:
Newer Post: Type TODO
Older Post: UI Composition
Kyle Shevlin's face, which is mostly a beard with eyes

Kyle Shevlin is the founder & lead software engineer of Agathist, a software development firm with a mission to build good software with good people.

Logo for Array.reduce()
Array.reduce()
Check out my courses!
If you enjoy my posts, you might enjoy my courses, too. Click the button to view the course or go to Courses for more information.
Sign up for my newsletter
Let's chat some more about TypeScript, React, and frontend web development. Unsubscribe at any time.