Dot Notation Type Accessor in TypeScript

calebpitan

Caleb Adepitan

Posted on December 26, 2022

Dot Notation Type Accessor in TypeScript

I was randomly scrolling Twitter and came across this—

Hmm, ¿son un tío o una mujer? No sé.

But anyway their name was trash, and yeah I mean "trash". Okay, I just did some digging on their profile and his real name is
Chris Bautista, you can hear him say "I'm trash" like "I am Groot".

Back to what I was saying—I don't know what it is with me and not keeping to topic. I just want to say so many things in so little
time, and it's killing me, argh!

Yeah, back to what I was saying, for real: @trashh_dev wrote this tweet about someone making react i18n type-safe. From the
moment I saw "made react i18n type safe", I was intrigued, like, "how did they do that?". I saw the code snippet following and I thought
to try it out. Brethren, lo and behold, it was legit.

The code snippet was easy to understand, but I never knew TypeScript could infer string literals. I guess that was my #TIL for
the day.

So after seeing that, I thought about another use case. One that has always been common with me—accessing property types from a parent
type or interface.

interface User {
  name: string
  nick: string
  dateOfBirth: Date
  address: {
    city: string
    state: string
    country: string
    zip: string
  }
}
Enter fullscreen mode Exit fullscreen mode

So given this User type, say I need just the address type, this is what I'll do in TypeScript.

const address: User['address'] = {
  city: 'Ikeja',
  state: 'Lagos',
  country: 'Nigeria',
  zip: '100001',
}
Enter fullscreen mode Exit fullscreen mode

This is fine! But it starts looking not so fine when the type gets deeper.

interface User {
  name: string
  nick: string
  dateOfBirth: Date
  address: {
    city: string
    state: string
    country: string
    zip: string
    coords: {
      lat: number
      lng: number
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, we only just need coords so let's pluck it:

const coordinates: User['address']['coords'] = { lat: 7.152627, lng: 3.657474 }
Enter fullscreen mode Exit fullscreen mode

You may be thinking:

  1. Why would anyone have to do this?
  2. Why not just make an Address type and a Coordinates type?

Plus one for being a critical thinker.

Let's try to tinker since we've got a thinker amongst us:

interface Coordinates {
  lat: number
  lng: number
}

interface Address {
  city: string
  state: string
  country: string
  zip: string
  coords: Coordinates
}

interface User {
  name: string
  nick: string
  dateOfBirth: Date
  address: Address
}
Enter fullscreen mode Exit fullscreen mode

And then we can do better:

const coordinates: Coordinates = { lat: 7.152627, lng: 3.657474 }

const address: Address = {
  city: 'Ikeja',
  state: 'Lagos',
  country: 'Nigeria',
  zip: '100001',
  coords: coordinates,
}
Enter fullscreen mode Exit fullscreen mode

But trust me, not every value is made of a type—some types are made of a value—because you may never know the type till after the value
has been created (or composed). Thank goodness an example isn't far from us and I didn't even write it.

Take a look at the code snippet in the image tweeted by trash embedded above: the type of Translations isn't known until after the
value of translations has been composed. Some other times this same pattern may be adopted to avoid duplication. When the type and the
value would eventually end up being the same, then we can just infer the type from the value using a typeof (TypeScript) operator.

const translations = {
  translationOne: 'hello world',
  translationTwo: 'hello {{world}}',
  translationThree: "hello {{world}}, I'm {{name}}!",
  translationFour: '{{foo}} has a {{bar}}',
} as const

type Translations = typeof translations
Enter fullscreen mode Exit fullscreen mode

The value of translations above isn't made of a type, whereas the type of Translations is made of a value.

Few reasons why this may be the case:

  • To avoid duplication, types are inferred from object values using the (TypeScript) typeof operator.
  • Sometimes the values are known before the types because it's the value that makes the type.
  • Some libraries may not export all the types contained in a parent type (i.e these child types are kept private to the library and only just exist on their parent interface or type)

How I plan to make this better

So just like lodash where you can access object properties using the dot notation, we'll create a Choose type
that can help us choose the type of child properties from a parent type.

Unlike in lodash, accessing array or tuple types with
bracket–index notation is not accounted for.

export type Choose<
  T extends Record<string | number, any>,
  K extends string
> = K extends `${infer U}.${infer Rest}` ? Choose<T[U], Rest> : T[K]
Enter fullscreen mode Exit fullscreen mode

This is just like a normal recursive function:

  1. Infer the first string literal that appears before a dot as a type U.
  2. Pack subsequent string literals appearing after a dot into a type Rest.
  3. So long as type K has a dot within it, keep calling this type on type T[U] while passing Rest as the new type of K.
  4. Otherwise, terminate at the point where K has no more dots and return the type T[K]

Note that this would break if the object key(s) contains a
dot which is our chosen delimiter.

const coordinates: Choose<User, 'address.coords'> = {
  lat: 7.152627,
  lng: 3.657474,
}

const address: Choose<User, 'address'> = {
  city: 'Ikeja',
  state: 'Lagos',
  country: 'Nigeria',
  zip: '100001',
  coords: coordinates,
}
Enter fullscreen mode Exit fullscreen mode

The only shortcoming of this known to me is that we can't get static type inference on the second parameter of Choose which would be
the dot-notated keys of the type T, in this case, User.

There are ways to fix this, but not so reliant, because it's a recursive computation too, and type T can be just any type such that we
can't tell how deep it gets. TypeScript starts complaining after certain levels of recursive computing.

type Join<K extends string | number, P extends string | number> = `${K}.${P}`

export type DeepProps<
  T extends Record<string | number, any>,
  K extends Exclude<keyof T, symbol> = Exclude<keyof T, symbol>,
  U extends string | number = ''
> = T[K] extends Record<string | number, unknown>
  ?
      | (U extends '' ? K : U)
      | DeepProps<
          T[K],
          Exclude<keyof T[K], symbol>,
          U extends ''
            ? Join<K, Exclude<keyof T[K], symbol>>
            : U | Join<U, Exclude<keyof T[K], symbol>>
        >
  : U
Enter fullscreen mode Exit fullscreen mode

If you want me to explain what's going on here, it may take me writing another post or maybe not. Not that it's difficult, but I currently
can't think of a way to explain it that'll be concise enough. All I can say is, if you care enough, copy it, break it and reconstruct the
logic bottom up, one step at a time, and see how the logic composes to achieve the desired result.

Alright, alright, I've heard you! Here you go:

  1. We have a Join<K, P> type that concats two string literal types K and P delimiting them with a dot.
  2. A DeepProps<T> type that enumerates all the paths in a type or an interface.
  3. DeepProps<T> has internal args K and U, where K is the keyof T with symbol excluded and U is the eventually joined paths carried from one call of DeepProps<T, K, U> to another.
  4. If U is currently '', i.e no paths have been joined, union K which is the keyof the current type T with a call to DeepProps<T[K], keyof T[K], ...>.
  5. U is carried to the next call of DeepProps so it can be further union'd with each successive call till a base case is hit where only U is returned.

If you are familiar with recursive functions from normal day-to-day programming this will be easy to comprehend. All the patterns used are
similar to those used with recursive routines.

type Keys = DeepProps<{
  app: { game: { lame: number; blame: string; same: { came: string } } }
}>

// type Keys = "app" | "app.game" | "app.game.lame" | "app.game.blame" | "app.game.same"
Enter fullscreen mode Exit fullscreen mode

Notice that "app.game.same.came" isn't present in the permutations. For whatever reason, it goes as deep as is common to all keys at a
certain level.

export type Choose<
  T extends Record<string | number, any>,
  K extends DeepProps<T>
> = K extends `${infer U}.${infer Rest}` ? Choose<T[U], Rest> : T[K]
Enter fullscreen mode Exit fullscreen mode

Now, you get type inference on the second parameter K which is a permutation of the keys in object T strung together with a dot
wherever necessary

If you used this type on another object, TypeScript might choke on it, so it's just safe to not use it at all.

Type instantiation is excessively deep and possibly infinite. ts(2589)<br>
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7b9ailz3n1lu1fnaa424.jpg)

After all considerations, our eventual type is this:

export type Choose<
  T extends Record<string | number, any>,
  K extends string
> = K extends `${infer U}.${infer Rest}` ? Choose<T[U], Rest> : T[K]
Enter fullscreen mode Exit fullscreen mode

and TypeScript won't choke along the way!

💖 💪 🙅 🚩
calebpitan
Caleb Adepitan

Posted on December 26, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related