Writing an Object Path Generic in TypeScript
type User = {
name: string;
address?: { zip: number; };
emails: [string];
};
users.sort(compareFn('n- name
- emails.0
- address.zip
- …
ame'));
For my compareFn
building library, ts-compare-fn,
I needed a way to specify a path to a property of an object in a type-safe way.
I wanted to allow for nested properties, optional properties, and tuple elements. I also wanted to make sure that the path leads to a property of a type that my comparison function can handle.
I ended up with a powerful recursive generic type that uses advanced TypeScript features like conditional types and template literal types, and solves a couple of non-trivial problems along the way.
Let's build it step by step.
Basic Path generic
For our first attempt, we'll map recursively over the keys of the Object and build out a string path with template literal types.
type Path<Object> = {
// Map over the object keys, restricted to strings
[Key in (keyof Object & string)]:
// If the value is itself an object type, recurse
Object[Key] extends object
? `${Key}.${Path<Object[Key]>}`
// Otherwise, yield the key
: Key;
}[keyof Object & string] // Get the union of paths behind every key
type User = {
name: string;
contacts: {
email: string;
}
}
type UserPathtype UserPath = "name" | "contacts.email"
= Path<User>;
A Path that leads to...
For the comparison function, we'll need to make sure that the path leads
to a property of a type that we can compare. Let's handle strings, numbers,
booleans, bigints, and Dates.
We'll intentionally exclude mixed types, like number | string
. We can't be sure
what the developer expects in this case, and this will highlight to them that
an explicit choice needs to be made.
type User = {
name: string; // we can compare strings
age: number; // or numbers
registeredAt: Date; // or some objects, like Dates
phone: number | string; // but not mixed types
}
Let's add another parameter to our Path
generic to specify the type of the property
the path should lead to. We'll call it Leaf
.
type Path<Object, Leaf> = {
[Key in (keyof Object & string)]:
Object[Key] extends Leaf
? Key
: Object[Key] extends object
? `${Key}.${Path<Object[Key], Leaf>}`
: never;
}[keyof Object & string]
This works with primitive types, built-in objects like Date
, and custom types.
For example, to extract all object paths that lead to strings or to Dates,
we can write something like this:
type User = {
name: string;
contacts: {
email: string;
phone: number | string;
}
registeredAt: Date;
}
type StringPathtype StringPath = "name" | "contacts.email"
= Path<User, string>;
type DatePathtype DatePath = "registeredAt"
= Path<User, Date>;
type BooleanPathtype BooleanPath = never
= Path<User, boolean>;
type SortableUserPath = StringPath | DatePath;
The last line can become a bit repetitive if we need to specify multiple types. It might be tempting to create another abstraction, using the fact that TS conditional types distribute over unions.
type ToPath<Object, Leafs> = Leafs extends any
? Path<Object, Leafs>
: never;
type SortablePathtype SortablePath<T> = Path<T, string> | Path<T, number> | Path<T, Date>
<T> = ToPath<T, string | number | Date>;
But we need to keep in mind that TypeScript distributes eagerly, for example
boolean
is treated as true | false
. This can lead to unexpected results:
type Conjectures = {
FermatsLastTheorem: true;
MertensConjecture: false;
PEqualsNP: boolean;
};
type SortablePathtype SortablePath = "FermatsLastTheorem" | "MertensConjecture"
= ToPath<Conjectures, boolean>;
Note that PEqualsNP
is not included in the resulting union, because boolean
doesn't extend true
nor false
. So we'll need to handle booleans separately.
For readability, we'll stick with explicit union of sortable paths.
// Shorter, but less readable
type SortablePath<Object> =
| ToPath<Object, string | number | bigint | Date>
| Path<Object, boolean>;
// More verbose, but clearer
type SortablePath<Object> =
| Path<Object, string>
| Path<Object, number>
| Path<Object, bigint>
| Path<Object, Date>
| Path<Object, boolean>;
Handling optional and nullable properties
We also want to allow for optional and nullable properties in our paths. To ignore
the optional modifier (?
), we can wrap the Object
type with Required<>
.
To handle null
and undefined
, we can create a Nullishable<>
type and use
it to wrap our Leafs
.
type Path<
Object,
Leaf,
// Wrap Object once and use it throughout the generic
ReqObj extends Required<Object> = Required<Object>
> = {
[Key in (keyof ReqObj & string)]:
ReqObj[Key] extends Leaf
? Key
: ReqObj[Key] extends object
? `${Key}.${Path<ReqObj[Key], Leaf>}`
: never;
}[keyof ReqObj & string]
type Nullishable<Type> = Type | null | undefined;
type SortablePath<Object> =
| Path<Object, Nullishable<string>>
| Path<Object, Nullishable<number>>
| Path<Object, Nullishable<bigint>>
| Path<Object, Nullishable<Date>>
| Path<Object, Nullishable<boolean>>;
Working with tuples
Resulting Path
generic works with tuples, but not arbitrary-length arrays.
And this makes sense for our use case: the developer probably wouldn't want to sort
by something like tags.99.name
. However, sorting by the first array element
may sometimes be useful. To achieve this, we can type the property as [T, ...T]
.
type Point = {
coords: [number, number];
tags: [string, ...string[]];
}
type Ptype P = "coords.0" | "coords.1" | "coords.length" | "tags.0" | "tags.length"
= SortablePath<Point>;
Final touches and variations
As a final touch, let's ignore the properties that contain .
in their names,
to simplify our JavaScript implementation. This is the version I used in my library.
type NoSpecialChars<Key extends string> =
Key extends `${string}.${string}` ? never : Key;
type Nullishable<Type> = Type | null | undefined;
type Path<
Object,
Leaf,
ReqObj extends Required<Object> = Required<Object>
> = {
[Key in (keyof ReqObj & string)]:
ReqObj[Key] extends Leaf
? NoSpecialChars<Key>
: ReqObj[Key] extends object
? `${Key}.${Path<ReqObj[Key], Leaf>}`
: never;
}[keyof ReqObj & string];
type SortablePath<Object> =
| Path<Object, Nullishable<string>>
| Path<Object, Nullishable<number>>
| Path<Object, Nullishable<bigint>>
| Path<Object, Nullishable<Date>>
| Path<Object, Nullishable<boolean>>;
type Dotted = {
'dot.ted': string;
plain: string;
}
type PathOfDottedtype PathOfDotted = "plain"
= SortablePath<Dotted>;
For some use cases, like specifying object dependency graphs (think React.useCallback
deps array, but stringified), you don't care about leaf types or indeed if the
property is a leaf or has children.
You can use the following simplified version in that case:
type Path<Object> = {
[Key in (keyof Object & string)]:
Object[Key] extends object
? Key | `${Key}.${Path<Object[Key]>}`
: Key;
}[keyof Object & string];
type Data = {
a: {
b: {
c: {
d: string;
}
}
}
}
type DependsOntype DependsOn = "a" | "a.b" | "a.b.c" | "a.b.c.d"
= Path<Data>;