
How to write type-safe nested key paths in TypeScript
Recently I had to write a type-safe function that allowed to update (nested) paths of a JavaScript object. I wanted the function to prevent setting a wrong value to the requested path, even nested ones with optional values.
After playing a bit with TypeScript type system I came up with these types which I think can be useful and will be using for sure in future projects. You can play with it in this playground or see the code right below:
export type KeyPath<
T extends string,
K extends string,
Separator extends string = ".",
> = `${T}${"" extends T ? "" : Separator}${K}`;
export type KeyPaths<T extends object, P extends string = "", Separator extends string = "."> = {
[K in keyof T]-?: K extends string
? NonNullable<T[K]> extends object
? KeyPath<P, K, Separator> | KeyPaths<NonNullable<T[K]>, KeyPath<P, K, Separator>, Separator>
: KeyPath<P, K, Separator>
: never;
}[keyof T & string];
export type GetTypeAtKeyPath<T, Path extends string, Separator extends string = "."> = Path extends keyof T
? T[Path]
: Path extends `${infer K}${Separator}${infer Rest}`
? K extends keyof T
? NonNullable<T[K]> extends object
? GetTypeAtKeyPath<NonNullable<T[K]>, Rest, Separator>
: never
: never
: never;
Code language: TypeScript (typescript)
Let’s dig into how to use it and how KeyPath
, KeyPaths
and GetTypeAtKeyPath
work and how to use it.
List key paths with KeyPath
First we define a type for a KeyPath
, that is, sequence of paths joined with a specific separator (usually a dot, .
, but we make it customizable). To build this type we use TypeScript’s template literal types.
export type KeyPath<
T extends string,
K extends string,
Separator extends string = ".",
> = `${T}${"" extends T ? "" : Separator}${K}`;
Code language: TypeScript (typescript)
We will leverage KeyPath
type to extract each component of a key path.
Check key paths with KeyPaths
Next we need a type that represents all possible key paths for a given object. The simplest scenario is when the object has no nested objects, in this situation its keys are its key paths as there is no nesting. The complex scenario is when the object has nested keys: each key is a key path but we also have to extract all the key paths for the nested objects and prefix them with the key in the parent object where we found the nested object. Recursion, that is.
So we define a type KeyPaths
that given an object, a “parent key path” and a separator, it represents all the key paths in the object, prefixed with the “parent key path”.
export type KeyPaths<
// The object we will extract key paths from
T extends object,
// The key path to this object, empty string for object's root
P extends string = "",
// The separator for key paths
// Default to dot (.) because it's a frequent notation
Separator extends string = "."
> = {
// Type a new object where values are the union of possible key paths with key as prefix
// We disregard nullable values with -?
// nullable values leak undefined into keys
[K in keyof T]-?: K extends string
// Remove nullable to look for nested objects
? NonNullable<T[K]> extends object
// There's a nested object, so we must explore it
? KeyPath<P, K, Separator> | KeyPaths<NonNullable<T[K]>, KeyPath<P, K, Separator>, Separator>
// No nested object, we only return the current key
: KeyPath<P, K, Separator>
// Key is not a string so we can't really build a key path with it
: never;
}[keyof T & string]; // Extract all values in the new object
Code language: TypeScript (typescript)
Get type for key path with GetTypeAtKeyPath
We also need some type to extract the type at a given key path, let’s call it GetTypeAtKeyPath
. To do so we build on top of KeyPath
foundations. We begin by extracting the first key of the key path, then extracting the type of the value at that key and finally repeating the process with the rest of the key path and the value we just extracted. What if we reach the last key in the key path? We just return the type of the value at that key.
export type GetTypeAtKeyPath<
// The object to extract types from
T,
// The key path
Path extends string,
// Key path separator
Separator extends string = "."
> = Path extends keyof T
// Key path is an actual key of this object
? T[Path]
// Extract first key in the key path
: Path extends `${infer K}${Separator}${infer Rest}`
// Check that first key in key path is an actual key of the object
? K extends keyof T
// Ensure the key path is referring to a nested object
? NonNullable<T[K]> extends object
// Extract type for remaining key path in nested object
? GetTypeAtKeyPath<NonNullable<T[K]>, Rest, Separator>
// Value for first key is not an object so key path is invalid
: never
// First key in the path is not a key of the object so key path is invalid
: never
// Key path is wrongly formatted
: never;
Code language: TypeScript (typescript)
Type-safe getters/setters
Combining KeyPaths
and GetTypeAtKeyPath
we can define a generic type-safe getters and setters. Let’s assume we have a simple User
data structure like:
interface User {
name: {
first: string
last: string
}
birth?: {
year: number
}
}
const me: User = {
name: {
first: 'Lluís',
last: 'Ulzurrun de Asanza Sàez',
},
}
Code language: TypeScript (typescript)
A type-safe key-path based setter for the User
data structure would look like:
const updateUserAttribute = <
// Hold the key path here to refer it later
K extends KeyPaths<User>
>(
// Given a user
user: User,
// Some key path
keyPath: K,
// And a valid value
newValue: GetTypeAtKeyPath<User, K>
) => {
// Implementation is not important
}
// This is valid at build time
updateUserAttribute(me, 'birth.year', 0)
updateUserAttribute(me, 'birth', undefined)
// This will fail at build time
updateUserAttribute(me, 'birth.year', '0')
Code language: TypeScript (typescript)
Finally we can define a type-safe key-path getter for the User
data structure with:
const getUserAttribute = <
// Hold the key path here to refer it later
K extends KeyPaths<User>
>(
// Given a user
user: User,
// Some key path
keyPath: K
):
// Returns a valid value
GetTypeAtKeyPath<User, K> => {
// The compiler detects this return value as wrong as expected (birth year is a number!)
return 'some value'
}
// This type is properly inferred
const name = getUserAttribute(me, 'name.first')
Code language: TypeScript (typescript)
No replies on “How to write type-safe nested key paths in TypeScript”