Modeling Lodash’s get() with TypeScript generics

by Brian Simon ()

Lodash’s get() function looks up a value at a deep object path. Optional chaining (?.) and nullish coalescing (??) cover many of the same cases in modern JavaScript, but get() remains useful when the path is data rather than syntax.

get() takes a target object, a path, and an optional default value to return when the lookup evaluates to undefined:

const value = _.get(target, 'path.to.some.property', defaultValue);

We’ll build a production-ready type definition that resolves deep paths, validates path strings, infers default values, and provides autocomplete.

Resolving the path string

The ResolvePath type will power the return type of our new _.get() signature. To match the function’s runtime behavior, it must evaluate to undefined when the path doesn’t exist on the target.

Start with top-level properties: if Path is a key of Target, return that property’s type.


type ResolvePath<Target, Path extends string> =
  Path extends keyof Target
    ? Target[Path]
    : undefined;


type value = ResolvePath<{ a: number }, 'a'>;
type value = number
type undefinedValue = ResolvePath<{ a: number }, 'b'>;
type undefinedValue = undefined
Try

Deep properties require recursion.

type ResolvePath<Target, Path extends string> =
  // first, check if we need to recurse. If there are two parts, pluck them into new
  // type parameters called `Head` and `Rest`.
  Path extends `${infer Head}.${infer Rest}`
  ? Head extends keyof Target
    // if `Head` is a key of `Target`, then we can recurse
    ? ResolvePath<Target[Head], Rest>

    // otherwise, we can fail early because this isn't a valid path
    : undefined

  // if `Path` only has one segment, check if it's a key of `Target`
  : Path extends keyof Target
    ? Target[Path]

    // if not, this is an invalid path
    : undefined;

type SomeType = {
  a: {
    b: {
      c: number;
    }
  }
};

type value = ResolvePath<SomeType, 'a.b.c'>;
type value = number
type undefinedPath = ResolvePath<SomeType, 'a.b.d'>;
type undefinedPath = undefined
Try

The infer keyword inside a template literal type splits the path so we can process one segment at a time.

The recursion progresses like this:

type SomeType = { a: { b: { c: number; } } };

ResolvePath<SomeType, 'a.b.c'>;

// 'a' gets picked off the front of the path string, leaving us with 'b.c'.
ResolvePath<SomeType['a'], 'b.c'>;

// next, 'b' is picked off, leaving us with 'c'
ResolvePath<SomeType['a']['b'], 'c'>; // evaluates to `number`

Array index access

Array lookups can appear in three forms:

  • An index in the middle of a path, such as someArray.5.someProperty or someArray[5].someProperty.
  • An index at the end of a path, such as someArray.5 or someArray[5].
  • A nullable or optional array property.

Because both recursive and terminal segments need this logic, we’ll extract it into ArrayIndexAccess.

type ArrayIndexAccess<
  Target,
  PathPart,
> =
  // first, check for bracketed syntax
  PathPart extends `${infer Key}[${number}]`
  ? Key extends keyof Target
    ? NonNullable<Target[Key]> extends (infer ArrayItem)[]
      ? ArrayItem
      : never
    : never

  // if it's not bracketed syntax, maybe it's dotted numeric syntax
  : PathPart extends `${number}`
    ? Target extends (infer ArrayItem)[]
      ? ArrayItem
      : never
    : never;

type ResolvePath<Target, Path extends string> =
  Path extends `${infer Head}.${infer Rest}`
    ? Head extends keyof Target
      ? ResolvePath<NonNullable<Target[Head]>, Rest>
      : ArrayIndexAccess<Target, Head> extends infer ArrayItem
        ? ResolvePath<ArrayItem, Rest> | undefined
        : never
    : Path extends keyof Target
      ? Target[Path]
      : ArrayIndexAccess<Target, Path> extends infer ArrayItem
        ? ArrayItem | undefined
        : undefined;


type dotted = ResolvePath<{ a: { b: { c: number }[] } }, 'a.b.0.c'>;
type dotted = number | undefined
type bracketed = ResolvePath<{ a: { b: { c: number }[] } }, 'a.b[0].c'>;
type bracketed = number | undefined
Try

Nullish properties

In TypeScript, keyof null and keyof undefined evaluate to never. Because keyof on a union returns only keys shared by every constituent, adding null or undefined also produces never, even when another constituent has known keys.

type A = keyof ({ a: string } | undefined);
type A = never
type B = keyof ({ a: string } | null);
type B = never
Try

That breaks the current recursive type:


type ResolvePath<Target, Path extends string> =
  Path extends `${infer Head}.${infer Rest}`
    ? Head extends keyof Target
      ? ResolvePath<Target[Head], Rest>
///                 ^^^^^^^^^^^^
// we have `keyof Target` sprinkled throughout this type. Since `Target[Head]` might
// possibly be null or undefined, passing `Target[Head]` into the recursion could actually result in
// those `keyof Target` clauses evaluating to `never`, which would satisfy a termination condition
// when we actually wanted to keep recursing
      : ArrayIndexAccess<Target, Head> extends infer ArrayItem
        ? ResolvePath<ArrayItem, Rest> | undefined
        : undefined
    : Path extends keyof Target
      ? Target[Path]
///     ^^^^^^^^^^^^
      : ArrayIndexAccess<Target, Path> extends infer ArrayItem
        ? ArrayItem | undefined
        : undefined;

// in this example, `b` is an optional property
type value = ResolvePath<{ a: { b?: { c: number } } }, 'a.b.c'>;
type value = undefined
Try

Use the built-in NonNullable<> helper before recursing, then restore undefined in the result to reflect a lookup that can fail:


type ResolvePath<Target, Path extends string> =
  Path extends `${infer Head}.${infer Rest}`
    ? Head extends keyof Target
      ? | ResolvePath<NonNullable<Target[Head]>, Rest>
///                   ^^^^^^^^^^^^^^^^^^^^^^^^^
        | Extract<Target[Head], undefined>
        | (null extends Target[Head] ? undefined : never)
// Since we're now excluding `null` and `undefined` to enable the `keyof` operator to work how we want it to
// we need add `undefined` back to the result of the recursion
      : ArrayIndexAccess<Target, Head> extends infer ArrayItem
        ? ResolvePath<ArrayItem, Rest> | undefined
        : undefined
    : Path extends keyof Target
      ? Target[Path]
      : ArrayIndexAccess<Target, Path> extends infer ArrayItem
        ? ArrayItem | undefined
        : undefined;

type value = ResolvePath<{ a: { b: { c: number } } }, 'a.b.c'>;
type value = number
type withNull = ResolvePath<{ a: { b: null | { c: number } } }, 'a.b.c'>;
type withNull = number | undefined
type withOptional = ResolvePath<{ a: { b?: { c: number } } }, 'a.b.c'>;
type withOptional = number | undefined
Try

Union types

Union members don’t necessarily share every property:


type User = {
  userId: string;
};

type Post = {
  postId: string;
}

type OhNo = ResolvePath<User | Post, 'userId'>;
type OhNo = undefined
// Since it's a string on `User` and not defined on `Post`, we want ResolvePath to return `string | undefined`
Try

Because keyof returns only keys common to every union member, it rejects paths that exist on only some members. Distributive conditional types let us inspect each member separately and collect all of their keys.

type User = {
  name: string;
  userId: string;
};

type Post = {
  name: string;
  postId: string;
}

type WithNormalKeyof = keyof (User | Post);
type WithNormalKeyof = "name"
type DistributedKeyof<Target> = Target extends any ? keyof Target : never; // The distribution results in the following type being equivalent to `keyof User | keyof Post` type WithDistributedKeyof = DistributedKeyof<User | Post>;
type WithDistributedKeyof = "name" | "userId" | "postId"
Try

Inside the conditional branch, Target refers to one union member at a time. TypeScript unions the results afterward.

We also need distributed property access. A normal indexed access on User | Post permits only shared properties.


type User = {
  name: string;
  userId: string;
};

type Post = {
  name: string;
  postId: string;
}

type DistributedAccess<Target, Key> =
  Target extends any
    ? Key extends keyof Target
      ? Target[Key]
      : undefined
    : never;

// Because `userId` does not appear in both User and Post, we get an error
type WithNormalAccess1 = (User | Post)['userId'];
Property 'userId' does not exist on type 'User | Post'.2339
Property 'userId' does not exist on type 'User | Post'. // `name` appears on both types, so this is valid type WithNormalAccess2 = (User | Post)['name'];
type WithNormalAccess2 = string
// `userId` is a `string` on `User`, and not defined on `Post`, so it should be `string | undefined` type WithDistributedAccess = DistributedAccess<User | Post, 'userId'>;
type WithDistributedAccess = string | undefined
Try

Replace each keyof Target with DistributedKeyof<Target> and each indexed access with DistributedAccess:


type DistributedKeyof<Target> = Target extends any ? keyof Target : never;

type DistributedAccess<Target, Key> =
  Target extends any
    ? Key extends keyof Target
      ? Target[Key]
      : undefined
    : never;

type ArrayIndexAccess<
  Target,
  PathPart,
> =
  PathPart extends `${infer Key}[${number}]`
  ? Key extends DistributedKeyof<Target>
    ? NonNullable<DistributedAccess<Target, Key>> extends (infer ArrayItem)[]
      ? ArrayItem
      : never
    : never
  : PathPart extends `${number}`
    ? Target extends (infer ArrayItem)[]
      ? ArrayItem
      : never
    : never;

type ResolvePath<Target, Path extends string> =
  Path extends `${infer Head}.${infer Rest}`
    ? Head extends DistributedKeyof<Target>
      ? | ResolvePath<NonNullable<DistributedAccess<Target, Head>>, Rest>
        | Extract<DistributedAccess<Target, Head>, undefined>
        | (null extends DistributedAccess<Target, Head> ? undefined : never)
      : ArrayIndexAccess<Target, Head> extends infer ArrayItem
        ? ResolvePath<ArrayItem, Rest> | undefined
        : undefined
    : Path extends DistributedKeyof<Target>
      ? DistributedAccess<Target, Path>
      : ArrayIndexAccess<Target, Path> extends infer ArrayItem
        ? ArrayItem | undefined
        : undefined;


type User = {
  userId: string;
};

type Post = {
  postId: string;
}

type Result = {
  value: User | Post;
}

type CorrectUserId = ResolvePath<Result, 'value.userId'>;
type CorrectUserId = string | undefined
Try

Path validation

Conditional types can change the path parameter’s type based on its string literal. This lets ResolvePath validate one supplied path lazily while still offering autocomplete.

The path type must handle four states:

  1. Empty input produces an error but suggests top-level properties.
  2. Input ending in a period produces an error but suggests the next segment.
  3. Any other invalid path produces an error.
  4. A valid path produces no error.

type ValidPath<Target, Input extends string> =
  // #1: empty input
  Input extends ''
  ? DistributedKeyof<Target>

  // #2: input ends with period. validate the part before the period and show suggestions based on what may come next
  : Input extends `${infer Head}.`
    ? ResolvePath<Target, Head> extends infer Prop
      ? Prop extends undefined
        // returning `never` will cause the type checker to report an error when checking the parameter type
        // because nothing is assignable to `never`
        ? never
        : `${Head}.${string & DistributedKeyof<NonNullable<Prop>>}`
      : never

    // if the input doesn't end with a period, check if it's valid
    : ResolvePath<Target, Input> extends undefined
      // #3: input does not reference a valid path
      ? never

      // #4: input references a valid path
      : Input;
Try

ResolvePath recurses over the supplied path, not every property in the target. The target’s size therefore doesn’t determine its recursion depth. That makes this approach suitable for production types.


The new call signature

With ResolvePath and ValidPath complete, define the call signature.


export type Get = <
  Target,
  Path extends string,
  Property extends ResolvePath<Target, Path>,
  DefaultValue = Property
>(target: Target, path: ValidPath<Target, Path>, defaultValue?: DefaultValue) => Property extends undefined
  ? DefaultValue | NonNullable<Property>
  : Property;Try

The default value

The defaultValue parameter has an unconstrained DefaultValue type parameter, allowing a default that doesn’t match the property type at the path.

_.get(target, 'path.to.optional.number', 'some string'); // number | string
_.get(target, 'path.to.optional.number', 5); // number

The return type includes DefaultValue only when the path can evaluate to undefined. We can also reject a default value when the property can’t be undefined, because that default is unreachable. A conditional rest parameter implements this behavior.


export type Get = <
  Target,
  Path extends string,
  Property extends ResolvePath<Target, Path>,
  DefaultValue = Property
>(target: Target, path: ValidPath<Target, Path>, ...args:
  undefined extends Property
  ? [defaultValue?: DefaultValue]
  : []
  // using a conditional type attached to the rest parameter, we can determine whether to allow callers to pass
  // a defaultValue
) => Property extends undefined
  ? DefaultValue | NonNullable<Property>
  : Property;Try

Autocomplete

The result now has an inferred return type, errors for invalid paths, and autocomplete for each path segment:

Editor autocomplete offering the valid next path segments as a get() path string is typed, and flagging an invalid path with a type error

Full code

type DistributedKeyof<Target> = Target extends any ? keyof Target : never;

type DistributedAccess<Target, Key> =
  Target extends any
    ? Key extends keyof Target
      ? Target[Key]
      : undefined
    : never;

type ArrayIndexAccess<
  Target,
  PathPart,
> =
  PathPart extends `${infer Key}[${number}]`
  ? Key extends string & DistributedKeyof<Target>
    ? NonNullable<DistributedAccess<Target, Key>> extends (infer ArrayItem)[]
      ? ArrayItem
      : never
    : never
  : PathPart extends `${number}`
    ? Target extends (infer ArrayItem)[]
      ? ArrayItem
      : never
    : never;

type ResolvePath<Target, Path extends string> =
  Path extends `${infer Head}.${infer Rest}`
    ? Head extends DistributedKeyof<Target>
      ? | ResolvePath<NonNullable<DistributedAccess<Target, Head>>, Rest>
        | Extract<DistributedAccess<Target, Head>, undefined>
        | (null extends DistributedAccess<Target, Head> ? undefined : never)
      : ArrayIndexAccess<Target, Head> extends infer ArrayItem
        ? ResolvePath<ArrayItem, Rest> | undefined
        : undefined
    : Path extends DistributedKeyof<Target>
      ? DistributedAccess<Target, Path>
      : ArrayIndexAccess<Target, Path> extends infer ArrayItem
        ? ArrayItem | undefined
        : undefined;


type ValidPath<Target, Input extends string> =
  Input extends ''
  ? DistributedKeyof<Target>
  : Input extends `${infer Head}.`
    ? ResolvePath<Target, Head> extends infer Prop
      ? Prop extends undefined
        ? never
        : `${Head}.${string & DistributedKeyof<NonNullable<Prop>>}`
      : never
    : ResolvePath<Target, Input> extends undefined
      ? never
      : Input;

export type Get = <
  Target,
  Path extends string,
  Property extends ResolvePath<Target, Path>,
  DefaultValue = Property
>(target: Target, path: ValidPath<Target, Path>, ...args: (undefined extends Property ? [defaultValue?: DefaultValue] : [])) =>
  Property extends undefined
  ? DefaultValue | NonNullable<Property>
  : Property;


// ----

declare const _: {
  get: Get;
}

interface User {
  id: string;
  name: {
    first: string;
    last: string;
  },
  address: {
    street: string;
    city: string;
    state: string;
    postalCode: string;
  },
  favoriteFoods: Array<{
    name: string;
    type: string;
  }>
}

declare const user: User;

const name = _.get(user, 'name')
const name: { first: string; last: string; }
const streetAddressLength = _.get(user, 'address.street.length');
const streetAddressLength: number
const favoriteFood = _.get(user, 'favoriteFoods[0]');
const favoriteFood: { name: string; type: string; } | undefined
// Invalid paths throw errors const typo = _.get(user, 'addddress.street');
Argument of type '"addddress.street"' is not assignable to parameter of type 'never'.2345
Argument of type '"addddress.street"' is not assignable to parameter of type 'never'.
Try

Alternative path validation: enumerate every path

A common way to validate a string literal is to produce a union of allowed strings and let TypeScript check assignability. That works when the union has a predictable size. Object paths don’t have a predictable size, but examining this approach shows why lazy validation matters. As jcalz puts it, “let’s do it first and scold ourselves later.”

type ValidPath<Target> = /* something */;

// should be "a" | "a.b" | "a.b.c"
type paths = ValidPath<{
  a: {
    b: {
      c: unknown;
    }
  }
}>;

First, enumerate the keys of Target:

type ValidPath<Target> = {
  [Key in keyof Target]: Key;
}[keyof Target];

type paths = ValidPath<{ a: string; b: number; c: boolean; }>;
type paths = "a" | "b" | "c"
Try

This combines mapped types and indexed access types. The mapped type computes a value for each key in Target. Indexing it with keyof Target produces a union of those values.

Recursion needs a termination condition. Because Target[Key] eventually evaluates to never, we’ll start with Target extends never.

type ValidPath<Target> = Target extends never
  ? never
  : {
    [Key in string & keyof Target]: Key | `${Key}.${ValidPath<Target[Key]>}`
  }[string & keyof Target];Try

Instead of returning only Key, the mapped type joins it to the recursive result with ${Key}.${ValidPath<Target[Key]>}.

Because a template literal requires a compatible key type, string & keyof Target narrows Key to strings.

Special case #1: primitives

The first test exposes a problem: the union includes prototype methods from primitives such as number and string, and built-ins such as Date.


type paths = ValidPath<{ a: { b: { c: number } } }>;
type paths = "a" | "a.b" | "a.b.c" | "a.b.c.toString" | "a.b.c.toFixed" | "a.b.c.toExponential" | "a.b.c.toPrecision" | "a.b.c.valueOf" | "a.b.c.toLocaleString"
Try

Although these are valid get() paths, every string property adds 48 members to the union. A target with dozens or hundreds of string properties expands quickly:


type paths = ValidPath<{ a: string; b: string; c: Date; d: boolean; e: string; }>;
type paths = "a" | "b" | "c" | "d" | "e" | "a.toString" | "a.charAt" | "a.charCodeAt" | "a.concat" | "a.indexOf" | "a.lastIndexOf" | "a.localeCompare" | "a.match" | "a.replace" | "a.search" | ... 205 more ... | "e.length.toLocaleString"
Try

The returned prototype method would also lack a this binding, requiring .bind(), .call(), or .apply() before use:

_.get(target, 'path.to.some.number.toFixed').call(_.get(target, 'path.to.some.number'), 3);

To exclude these prototype keys, we need to alter our termination condition.

One approach is to stop recursing when the property value is a function.

type ValidPath<Target> = {
    [Key in string & keyof Target]: Target[Key] extends Function
      ? never
      : Key | `${Key}.${ValidPath<Target[Key]>}`
  }[string & keyof Target];

type paths = ValidPath<{ a: { b: { c: number } } }>;
type paths = "a" | "a.b" | "a.b.c"
// but what if we have our own non-prototype function that we want to include? type wrong = ValidPath<{ a: { b: { c: () => number } } }>;
type wrong = "a" | "a.b"
Try

Because that tradeoff is too broad, list the terminal types explicitly instead:

type Leaf =
  | Date
  | boolean
  | string
  | number
  | symbol
  | bigint;


type ValidPath<Target> = Target extends Leaf
    ? never
    : {
      [Key in string & keyof Target]: Key | `${Key}.${ValidPath<Target[Key]>}`
    }[string & keyof Target];

type paths = ValidPath<{ a: { b: { c: number } } }>;
type paths = "a" | "a.b" | "a.b.c"
type yay = ValidPath<{ a: { b: { c: () => number } } }>;
type yay = "a" | "a.b" | "a.b.c"
Try

Special case #2a: bracketed array access

interface Car {
  wheels: Wheel[];
}

declare const car: Car;

_.get(car, 'wheels[0]');
_.get(car, 'wheels[1]');
_.get(car, 'wheels[2]');
_.get(car, 'wheels[3]');

To accommodate this bracketed syntax, add a conditional type to check whether the property is an array.


type ValidPath<Target> = Target extends never
  ? never
  : Target extends Leaf
    ? never
    : {
    [Key in string & keyof Target]: Key | (
      Target[Key] extends (infer ArrayItem)[]
        ? `${Key}[${number}]` | `${Key}[${number}].${ValidPath<ArrayItem>}`
        : `${Key}.${ValidPath<Target[Key]>}`
    )
    }[string & keyof Target];Try

Here, infer captures the array’s item type as ArrayItem, allowing recursion into the item rather than the array itself.


type paths = ValidPath<{ a: { b: { c: number }[] } }>;
type paths = "a" | "a.b" | `a.b[${number}]` | `a.b[${number}].c`
Try

Special case #2b: dotted array access

get() accepts an alternate syntax for array index access:

// these are equivalent
_.get(car, 'wheels[0].tire');
_.get(car, 'wheels.0.tire');

We could add that syntax as a second recursive branch:


type ValidPath<Target> = Target extends never
  ? never
  : Target extends Leaf
    ? never
    : {
      [Key in string & keyof Target]: Key | (
        Target[Key] extends (infer ArrayItem)[]
        ? // bracket syntax
          | `${Key}[${number}]`
          | `${Key}[${number}].${ValidPath<ArrayItem>}`

          // dot syntax
          | `${Key}.${number}`
          | `${Key}.${number}.${ValidPath<ArrayItem>}`

        : `${Key}.${ValidPath<Target[Key]>}`
      )
    }[string & keyof Target];Try

That second recursion makes the union grow exponentially with each level of array nesting. Because the union is already large, we need to avoid generating both syntaxes at once.


type depth1 = ValidPath<{ a: number[]; }>;
type depth1 = "a" | `a[${number}]` | `a.${number}`
type depth2 = ValidPath<{ a: { b: {}[] }[]; }>;
type depth2 = "a" | `a[${number}]` | `a.${number}` | `a[${number}].b` | `a[${number}].b[${number}]` | `a[${number}].b.${number}` | `a.${number}.b` | `a.${number}.b[${number}]` | `a.${number}.b.${number}`
type depth3 = ValidPath<{ a: { b: { c: {}[] }[] }[]; }>;
type depth3 = "a" | `a[${number}]` | `a.${number}` | `a[${number}].b` | `a[${number}].b[${number}]` | `a[${number}].b.${number}` | `a.${number}.b` | `a.${number}.b[${number}]` | `a.${number}.b.${number}` | `a[${number}].b[${number}].c` | `a[${number}].b[${number}].c[${number}]` | `a[${number}].b[${number}].c.${number}` | ... 8 more ... | `a.${number}.b.${number}.c.${number}`
type depth8 = ValidPath<{ a: { b: { c: { d: { e: { f: { g: { h: { i: {}[] }[] }[] }[] }[] }[] }[] }[] }[]; }>;
type depth8 = "a" | `a[${number}]` | `a.${number}` | `a[${number}].b` | `a[${number}].b[${number}]` | `a[${number}].b.${number}` | `a.${number}.b` | `a.${number}.b[${number}]` | `a.${number}.b.${number}` | `a[${number}].b[${number}].c` | `a[${number}].b[${number}].c[${number}]` | `a[${number}].b[${number}].c.${number}` | ... 1520 more ... | `a.${number}.b.${number}.c.${number}.d.${number}.e.${number}.f.${number}.g.${number}.h.${number}.i.${number}`
Try

TypeScript computes every permutation of access syntaxes for nested arrays. For depth2, the result resembles this:

type depth2 =
  | `a[${number}].b[${number}]`
  | `a[${number}].b.${number}`
  | `a.${number}.b[${number}]`
  | `a.${number}.b.${number}`

Most codebases use one style rather than mixing paths such as a[0].b.4. Although get() accepts both at runtime, generating only one keeps the union smaller.

Using another indexed access type, we can make the syntax configurable:


type ValidPath<Target, ArrayAccessSyntax extends 'brackets' | 'dot' = 'dot'> = Target extends never
  ? never
  : Target extends Leaf
    ? never
    : {
      [Key in string & keyof Target]: Key | (
        Target[Key] extends (infer ArrayItem)[]
        ? {
          brackets: `${Key}[${number}]` | `${Key}[${number}].${ValidPath<ArrayItem, ArrayAccessSyntax>}`;
          dot: `${Key}.${number}` | `${Key}.${number}.${ValidPath<ArrayItem, ArrayAccessSyntax>}`;
        }[ArrayAccessSyntax]
        : `${Key}.${ValidPath<Target[Key], ArrayAccessSyntax>}`
      )
    }[string & keyof Target];Try

The new ArrayAccessSyntax generic selects one style. With only one recursive branch, this part of the union grows linearly.


type depth1 = ValidPath<{ a: number[]; }>;
type depth1 = "a" | `a.${number}`
type depth2 = ValidPath<{ a: { b: {}[] }[]; }>;
type depth2 = "a" | `a.${number}` | `a.${number}.b` | `a.${number}.b.${number}`
type depth3 = ValidPath<{ a: { b: { c: {}[] }[] }[]; }>;
type depth3 = "a" | `a.${number}` | `a.${number}.b` | `a.${number}.b.${number}` | `a.${number}.b.${number}.c` | `a.${number}.b.${number}.c.${number}`
type depth8 = ValidPath<{ a: { b: { c: { d: { e: { f: { g: { h: { i: {}[] }[] }[] }[] }[] }[] }[] }[] }[]; }>;
type depth8 = "a" | `a.${number}` | `a.${number}.b` | `a.${number}.b.${number}` | `a.${number}.b.${number}.c` | `a.${number}.b.${number}.c.${number}` | `a.${number}.b.${number}.c.${number}.d` | `a.${number}.b.${number}.c.${number}.d.${number}` | `a.${number}.b.${number}.c.${number}.d.${number}.e` | `a.${number}.b.${number}.c.${number}.d.${number}.e.${number}` | ... 7 more ... | `a.${number}.b.${number}.c.${number}.d.${number}.e.${number}.f.${number}.g.${number}.h.${number}.i.${number}`
Try

Special case #3: circular types

Consider this example:


interface User {
  id: string;
  name: string;
  friends: User[]; // circular property
}

type paths = ValidPath<User, 'dot'>;
Type of property 'friends' circularly references itself in mapped type '{ [Key in "id" | "name" | "friends"]: Key | (User[Key] extends (infer ArrayItem)[] ? `${Key}.${number}` | `${Key}.${number}.${ValidPath<ArrayItem, "dot">}` : `${Key}.${ValidPath<...>}`); }'.2615
Type of property 'friends' circularly references itself in mapped type '{ [Key in "id" | "name" | "friends"]: Key | (User[Key] extends (infer ArrayItem)[] ? `${Key}.${number}` | `${Key}.${number}.${ValidPath<ArrayItem, "dot">}` : `${Key}.${ValidPath<...>}`); }'.
Try

Here, ValidPath recurses indefinitely. TypeScript detects the cycle and reports "Type of property {Name} circularly references itself in mapped type" or "Type instantiation is excessively deep or possibly infinite".

When possible, I model the data’s actual nesting depth and remove the circular type. If an API populates a User’s friends only one level deep, the types can say so explicitly.


type User = {
  id: string;
  name: string;
};

type UserWithFriends = User & {
  friends: User[];
}

type paths = ValidPath<UserWithFriends>;
type paths = "id" | "name" | "friends" | `friends.${number}` | `friends.${number}.id` | `friends.${number}.name`
Try

When the types can’t be changed, such as when they come from a third-party library, a depth limit provides a fallback:


// using a tuple type, we can mimic a counter variable
type DepthLimiter = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

type ValidPath<
  Target,
  ArrayAccessSyntax extends 'brackets' | 'dot' = 'dot',
  Depth extends DepthLimiter[number] = 10
> = Depth extends never
  ? never
  : Target extends never
    ? never
    : Target extends Leaf
      ? never
      : {
        [Key in string & keyof Target]: Key | (
          Target[Key] extends (infer ArrayItem)[]
          ? {
            brackets: `${Key}[${number}]` | `${Key}[${number}].${ValidPath<ArrayItem, ArrayAccessSyntax, DepthLimiter[Depth]>}`;
            dot: `${Key}.${number}` | `${Key}.${number}.${ValidPath<ArrayItem, ArrayAccessSyntax, DepthLimiter[Depth]>}`;
          }[ArrayAccessSyntax]
          : `${Key}.${ValidPath<Target[Key], ArrayAccessSyntax, DepthLimiter[Depth]>}`
        )
      }[string & keyof Target];Try

Each recursive call passes DepthLimiter[Depth] as the next Depth: 10 becomes 9, then 8, and so on. At 0, the lookup produces never, and Depth extends never ? never stops the recursion.


interface User {
  id: string;
  friends: User[];
}

type paths = ValidPath<User, 'dot'>;
type paths = "id" | "friends" | `friends.${number}` | `friends.${number}.id` | `friends.${number}.friends` | `friends.${number}.friends.${number}` | `friends.${number}.friends.${number}.id` | `friends.${number}.friends.${number}.friends` | `friends.${number}.friends.${number}.friends.${number}` | `friends.${number}.friends.${number}.friends.${number}.id` | `friends.${number}.friends.${number}.friends.${number}.friends` | ... 21 more ... | `friends.${number}.friends.${number}.friends.${number}.friends.${number}.friends.${number}.friends.${number}.friends.${number}.friends.${number}.friends.${number}.friends.${number}.friends.${number}`
Try

Special case #4: null and undefined

Returning to the preceding example, what happens if we make friends optional?


type User = {
  id: string;
  name: string;
};

type UserWithFriends = User & {
  friends?: User[];
}

type paths = ValidPath<UserWithFriends>;
type paths = "id" | "name" | "friends" | "friends.length" | "friends.toString" | "friends.toLocaleString" | "friends.pop" | "friends.push" | "friends.concat" | "friends.join" | "friends.reverse" | ... 30 more ... | "friends.with"
Try

Now the union includes array prototype methods. When Target[Key] might be null or undefined, it doesn’t satisfy Target[Key] extends (infer ArrayItem)[], so TypeScript takes the wrong branch. Apply NonNullable<> before checking the property type.


type ValidPath<
  Target,
  ArrayAccessSyntax extends 'brackets' | 'dot' = 'dot',
  Depth extends DepthLimiter[number] = 10
> = Depth extends never
  ? never
  : Target extends never
    ? never
    : Target extends Leaf
      ? never
      : {
        [Key in string & keyof Target]: Key | (
          // Target[Key] was replaced with NonNullable<Target[Key]>
          NonNullable<Target[Key]> extends (infer ArrayItem)[]
          ? {
            brackets: `${Key}[${number}]` | `${Key}[${number}].${ValidPath<ArrayItem, ArrayAccessSyntax, DepthLimiter[Depth]>}`;
            dot: `${Key}.${number}` | `${Key}.${number}.${ValidPath<ArrayItem, ArrayAccessSyntax, DepthLimiter[Depth]>}`;
          }[ArrayAccessSyntax]
          : `${Key}.${ValidPath<NonNullable<Target[Key]>, ArrayAccessSyntax, DepthLimiter[Depth]>}`
        )
      }[string & keyof Target];
Try

type User = {
  id: string;
  name: string;
};

type UserWithFriends = User & {
  friends?: User[];
}

type paths = ValidPath<UserWithFriends>;
type paths = "id" | "name" | "friends" | `friends.${number}` | `friends.${number}.id` | `friends.${number}.name`
Try

Special case #5: union types

Reuse DistributedKeyof and DistributedAccess from the earlier union-type solution.


type DistributedKeyof<Target> = Target extends any ? keyof Target : never;

type DistributedAccess<Target, Key> =
  Target extends any
    ? Key extends keyof Target
      ? Target[Key]
      : undefined
    : never;

type Leaf =
  | Date
  | boolean
  | string
  | number
  | symbol
  | bigint;

type DepthLimiter = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

type ValidPath<
  Target,
  ArrayAccessSyntax extends 'brackets' | 'dot' = 'dot',
  Depth extends DepthLimiter[number] = 10
> = Depth extends never
  ? never
  : Target extends never
    ? never
    : Target extends Leaf
      ? never
      : {
        [Key in string & DistributedKeyof<Target>]: Key | (
          NonNullable<DistributedAccess<Target, Key>> extends (infer ArrayItem)[]
          ? {
            brackets: `${Key}[${number}]` | `${Key}[${number}].${ValidPath<ArrayItem, ArrayAccessSyntax, DepthLimiter[Depth]>}`;
            dot: `${Key}.${number}` | `${Key}.${number}.${ValidPath<ArrayItem, ArrayAccessSyntax, DepthLimiter[Depth]>}`;
          }[ArrayAccessSyntax]
          : `${Key}.${ValidPath<NonNullable<DistributedAccess<Target, Key>>, ArrayAccessSyntax, DepthLimiter[Depth]>}`
        )
      }[string & DistributedKeyof<Target>];


// --------

type User = {
  name: string;
  userId: string;
};

type Post = {
  name: string;
  postId: string;
}

type Result = {
  value: User | Post;
};

type paths = ValidPath<Result>;
type paths = "value" | "value.name" | "value.userId" | "value.postId"
Try

Why enumeration doesn’t scale

Recursive generic types have a real computation cost. The demo types are small enough that enumerating every path is inexpensive, but production models can contain many properties, object references, and cycles. Their path unions can delay autocomplete, exhaust the type checker’s memory, or exceed TypeScript’s 100,000-member limit with "Expression produces a union type that is too complex to represent".

Eager enumeration is a fun exercise, but it’s not a great choice for our use case. Lazy validation scales better because it resolves only the path the developer supplies.