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 UserPath
type 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 StringPath
type StringPath = "name" | "contacts.email"
= Path<User, string>; type DatePath
type DatePath = "registeredAt"
= Path<User, Date>; type BooleanPath
type 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 SortablePath
type 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 SortablePath
type 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 P
type 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 PathOfDotted
type 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 DependsOn
type DependsOn = "a" | "a.b" | "a.b.c" | "a.b.c.d"
= Path<Data>;