23 Sep 2020 Using TypeScript 4.0's variadic tuple types to typecheck your path into an object

There's two generic types that I've wanted to be able to implement in TypeScript for ages. Here's an example of them in use:

type FormData = {
  name: string,
  address: {street: string, suburb: string, postcode: number},

type PathsIntoFormData = PathInto<FormData>
// = ['name' | 'address'] | ['address', 'street' | 'suburb' | 'postcode']
type Street = TypeAtPath<FormData, ['address', 'street']>
// = string

Here, we have PathInto<T>, which returns a union of tuples, representing all possible "paths" of attribute access into nested levels of objects. If you can access the name property on T, then ['name'] will be a member of PathInto<T>; if you can access address.street on T, ['address', 'street'] will be; and so on.

We also have TypeAtPath<T, P> which takes one such tuple type as a parameter and resolves to the type you'd get as a result. So, for instance, if you access address.street on T and get a string result, then TypeAtPath<T, ['address', 'street']> will resolve to string.

Where might you want to use this? Form and state management libraries.

What problem do these types solve?

You might want to use types like this if you're building a form or state management library.

As an example, let's say we're using the useField() hook in the ever-popular Formik:

const [field, meta, helpers] = useField<number>('address.postcode')

This is fine until we try to send something to the Northern Territory, whose postcodes start at 0800, and we lose the leading zero; or we try sending to the UK where postcodes can have letters in them. So we update our FormData type... and then we find every occurrence of number in reference to that particular field, and replace it.

Instead, we could have written useField<FormData['address']['postcode']>('address.postcode')—that would have solved this issue! That repetition is unsightly, though, and it's only a matter of time until developers start omitting the annotation, leaving it to fall back to the default of any. There's also the potential for typos in the 'address.postcode' string, which isn't checked.

Instead, let's imagine a different API:

// type definition of our new useField
function useField<P extends PathInto<FormData>>(path: P) => FormField<TypeAtPath<P>>

// and a usage example
const field = useField(['address', 'postcode'])
// field will be of type FormField<number>

Here, we've used PathInto<T> to limit the possible arguments; if you try to call useField(['address', 'postcoed']) you'll get an error. TypeScript can also easily infer the type, so there's no repetition. We've also used TypeAtPath<T, P> to tell our return type what sort of field it is.

(I'm hand-waving away providing FormData itself to our form library's type signatures—Formik naturally can't hard-code FormData into its type signatures. A slightly different API could, but this article is long enough already without my going into the perils of React context.)

It's been sort of possible to implement PathInto and TypeAtPath in TypeScript for a while now, but it certainly wasn't easy or pretty. It involved a big, long chain of conditional types, one for each level of nesting you wanted to support, and plenty of arcane type system shenanigans at each level to actually construct the value you wanted. I've done it in a past project; the type definition for a PathInto<T> ends up being around 50 lines long, plus some extra private type definitions it uses internally, and even that only works for four levels of nesting.

Happily, TypeScript creator Anders Heljsberg added variadic tuple types to TypeScript 4.0, so now we can define both of these types fairly easily—and use them with as many levels of nesting as we like!

Defining PathInto

Our new PathInto type looks like this:

 type PathInto<T extends object> =
  | [keyof T]
  | {
    [K in keyof T]: T[K] extends object ? [K, ...PathInto<T[K]>] : never
  }[keyof T]

Before we break this down: I've tried to strike a balance between brevity and clarity, but we're using a lot of TypeScript's more advanced type system features at once here. If there's a feature you haven't come across enough to fully understand yet, odds are that Marius Schulz has an excellent explainer on it.

With that said, let's dive in.

Single-level types

First, we have [keyof T]. keyof T (without the square brackets) is the index type of T, which is the union of all its keys. For example, keyof {a: string, b: number} will be the type 'a' | 'b'. By putting it in square brackets we turn it into a 1-element tuple. At this point, for objects that are only a single layer of nesting deep, we're done!

type PathInto<T extends object> = [keyof T]
type PathIntoFormData = PathIntoPart1<FormData>
// = ['name' | 'address']

Filtering out the non-objects

Next we have this bit: {[K in keyof T]: ...}, which is a mapped type over the keys of T. It creates a new object type, where the key types are whatever is in keyof T, and the values are the right-hand side (which we've replaced with ... for now); K in the right-hand side is substituted for each key. For example, if we had {[K in keyof T]: K}, and T was {a: string, b: number}, the resulting type would be {a: 'a', b: 'b'}.

On the right-hand side of our mapped type, we have T[K] extends object ? ... : never. This is a conditional type, which works similarly to the JavaScript ternary operator. If T[K] is an object type, we'll get the left-hand side (again, replaced with ...); if not, we get the right-hand side—the never type.

type PathInto<T extends object> =
  | [keyof T]
  | {
    [K in keyof T]: T[K] extends object ? ... : never
type PathIntoFormData = PathInto<FormData>
// = ['name' | 'address'] | {address: ...}

Recursing with the variadic tuple type

Inside this, we have our variadic tuple type! [K, ...PathInto<T[K]>] is a tuple type that has K as its first element. Variadic tuple types work like the spread operator: PathInto<T[K]> gives us a tuple type, and then its contents is filled into the top-level tuple type.

type PathInto<T extends object> =
  | [keyof T]
  | {
    [K in keyof T]: T[K] extends object ? [K, ...PathInto<T[K]>] : never
type PathsIntoFormData = PathInto<FormData>
// = ['name' | 'address'] | {address: ['address', ...PathInto<FormData['Address']>]}
// = ['name' | 'address'] | {address: ['address', 'street' | 'suburb' | 'postcode']}

Extracting values from our mapped type

The last bit we need to deal with is that {address: ... } bit. We don't want the object, we just want what's inside it.

To get one specific key, we can write {...}['b'], but in some cases there'd be more than one key. Fortunately, indexing by a union of keys gives us a union of value types; for example, {x: T1, y: T2}['x' | 'y'] gives you T1 | T2. We already have access to the union of all possible keys in our mapped type, so we can write {...}[keyof T], and we'll get the union of all our 2-layer tuple types!

All done!

Now we just combine the two, and we get the result we wanted. And because our 2-layer tuple got filled in by applying PathInto recursively, the same type will work for 3-layer nested types, or 4-layer, or as many layers as you like.

 type PathInto<T extends object> =
  | [keyof T]
  | {
    [K in keyof T]: T[K] extends object ? [K, ...PathInto<T[K]>] : never
  }[keyof T]
type PathsIntoFormData = PathInto<FormData>
    // = ['name' | 'address'] | ['address', 'street' | 'suburb' | 'postcode']

Defining TypeAtPath

Here's my second type, TypeAtPath. A walkthrough of exactly what's going on here is coming in Part 2 of this post—but since you've stuck with me this far, you can have the type definition itself early. (You'll notice we're using most of the same tricks here, but there's a couple of new ones—one of them is a hack working around a restriction that'll go away in TypeScript 4.1 anyway.)

type TypeAtPath<T extends object, P extends any[]> = P extends [infer U]
    ? (U extends keyof T ? T[U] : never)
    : P extends [infer U, ...infer R]
    ? (U extends keyof T ? (
      T[U] extends infer U2 ? (
        U2 extends object ? [TypeAtPath<U2, R>] : never
      ) : never
    ) : never)[0]
    : any