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 a 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.
saw someone made react i18n type safe and i wanted to try it out. thanks to the homies for helping me pull it off.
— trash (@trashh_dev) August 12, 2022
args tip taken from @mattpocockuk great youtube video pic.twitter.com/TlNK5Pe4Lw
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 concatenates 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 the
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!
Update [19th March, 2023]: An elaborate DeepProps
type
So I found a better type implementation that can get the correct permutation of all possible paths in an object without choking TypeScript.
I found these from one of the libraries I use in some projects. I took a look at the implementation and saw it was a very solid one they achieved by having to use three other helper types.
One of these helper types checks if a type is any
.
While it’s important to know that:
- The types
unknown
andnever
don’t intersect. - The types
unknown
andany
do intersect. - Both
keyof never
andkeyof any
isstring | number | symbol
- While
keyof unknown
isnever
.
These are all very similar types (any
, unknown
and never
) that vary pretty much.
Here are some assumptions of mine on these three types that are quite alike:
any
means no discrimination and whatever type or any type can fit in.unknown
may seem likeany
, which you may think of also as unknown, but the difference is,any
is a valid type whileunknown
is not really a type; it’s more like a placeholder that must be filled with a valid type to make anything useful of it, hence thekeyof unknown
isnever
.never
seems more like a vacuum (or maybe more strictly, an abomination), an abomination of types, yet a valid type. The thing is, whilenever
is a valid type, there’s no single type that fits into it other than itself.
But how is it that the keyof never
is string | number | symbol
?
As I said never
is a valid type, and most of all valid types in TypeScript and values in
JavaScript, (with the exclusion of null
, undefined
and void
) they do have keys that somehow
extend string | number | symbol
. Other than primitives, all types in JavaScript are Object
s. But
primitives also do have their object representation and the key of all objects will either be a
string, number or symbol.
export type IsAny<T> = unknown extends T ? ([keyof T] extends [never] ? false : true) : false
For the second helper type of the three, this is where most of the trick is done. Here is the implementation of it in the source library.
type PathImpl<T, Key extends keyof T> = Key extends string
? IsAny<T[Key]> extends true
? never
: T[Key] extends Record<string, any>
?
| `${Key}.${PathImpl<T[Key], Exclude<keyof T[Key], keyof any[]>> & string}`
| `${Key}.${Exclude<keyof T[Key], keyof any[]> & string}`
: never
: never
Notice the line that has T[Key] extends Record<string, any>
.
It is important to know that arrays also pass the check as they are equally valid objects. So
there’s a problem. We could inadvertently include array properties like length
, find
, map
,
etc, in our paths, which I’m sure would be undesired. To avoid this, their implementation excludes
array keys from the keys of T
. But there’s a limitation there: any non-arraylike T
that has one
or more array properties in its keys will have those excluded in the eventual path.
Here is a modification I made to fix that limitation. We add an extra helper type, which if you are counting, now makes it four.
type ExcludeArrayKeys<T> = T extends ArrayLike<any> ? Exclude<keyof T, keyof any[]> : keyof T
This is the same idea as:
// T extends Record<...>
if (typeof value === 'object') {
// T extends ArrayLike<...>
if (Array.isArray(value)) {
// do something else
return
}
// do something
}
So if we infuse that one line of helper type, we’d overcome the said limitation.
type PathImpl<T, Key extends keyof T> = Key extends string
? IsAny<T[Key]> extends true
? never
: T[Key] extends Record<string, any>
?
| `${Key}.${PathImpl<T[Key], ExcludeArrayKeys<T[Key]>> & string}`
| `${Key}.${ExcludeArrayKeys<T[Key]> & string}`
: never
: never
The last helper is plain and simple and can even be avoided if it’s inlined with the main type, but it makes it easily readable, I guess.
This just makes sure the terminal paths at the root of the object are included with the other non-terminal paths at the root.
type PathImpl2<T> = PathImpl<T, keyof T> | keyof T
And a type is born!
export type Path<T> = keyof T extends string
? PathImpl2<T> extends infer P
? P extends string | keyof T
? P
: keyof T
: keyof T
: never
You are free to choose, no strings attached!
export type Choose<
T extends Record<string | number, any>,
K extends Path<T>
> = K extends `${infer U}.${infer Rest}`
? Rest extends Path<T[U]>
? Choose<T[U], Rest>
: never
: T[K]