Dot Notation Type Accessor in TypeScript
Caleb Adepitan
Posted on December 26, 2022
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
}
}
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',
}
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
}
}
}
Now, we only just need coords
so let's pluck it:
const coordinates: User['address']['coords'] = { lat: 7.152627, lng: 3.657474 }
You may be thinking:
- Why would anyone have to do this?
- Why not just make an
Address
type and aCoordinates
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
}
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,
}
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
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]
This is just like a normal recursive function:
- Infer the first string literal that appears before a dot as a type
U
. - Pack subsequent string literals appearing after a dot into a type
Rest
. - So long as type
K
has a dot within it, keep calling this type on typeT[U]
while passingRest
as the new type ofK
. - Otherwise, terminate at the point where
K
has no more dots and return the typeT[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,
}
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
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:
- We have a
Join<K, P>
type that concats two string literal typesK
andP
delimiting them with a dot. - A
DeepProps<T>
type that enumerates all the paths in a type or an interface. -
DeepProps<T>
has internal argsK
andU
, whereK
is thekeyof T
withsymbol
excluded andU
is the eventually joined paths carried from one call ofDeepProps<T, K, U>
to another. - If
U
is currently''
, i.e no paths have been joined, unionK
which is thekeyof
the current typeT
with a call toDeepProps<T[K], keyof T[K], ...>
. -
U
is carried to the next call ofDeepProps
so it can be further union'd with each successive call till a base case is hit where onlyU
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"
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]
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.
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]
and TypeScript won't choke along the way!
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
November 28, 2024