Modeling Lodash’s get() with TypeScript Generics

by Brian Simon ()

One of my favorite functions in the lodash library is the get() function. get() is a useful utility function that looks up a deep value in an object. While the optional chaining (?.) and nullish coalescing (??) operators have mostly replaced the need for get() in modern JavaScript, many developers will still reach for get() out of habit.

get() takes 3 parameters: a target object, a path, and optionally, a default value to return if the lookup evaluates to undefined.

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

In this blog post, we’ll develop a production-ready type definition for get() from scratch using TypeScript generics.

Resolving the Path String

The first type we’ll create resolves the path string, which we’ll call ResolvePath

ResolvePath will power the return type for our new _.get() call signature. We need to ensure that this type evaluates to undefined when the path does not exist on the target so that it aligns with the runtime behavior of the function.

Let’s start with resolving top-level properties. If Path is a key on Target, resolve the value type.

ts
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

Since we want to look up a deep property, we must make this type recursive.

ts
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

Here, we’re using the infer keyword inside of a template literal type to break apart the string literal so we can work with path segment individually.

A rough representation of the recursion might look like this:

ts
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

We need to handle a few cases when the lookup includes an array access:

  • array index access in the middle of a path: someArray.5.someProperty / someArray[5].someProperty
  • array index access at the end of a path: someArray.5 / someArray[5]
  • array property is nullable or optional

Since we need to check for this syntax in two positions, we’ll create a new type called ArrayIndexAccess to reuse in both.

ts
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

Handling Nullish Properties

In TypeScript, keyof null and keyof undefined both evaluate to never. When representing the keys of a union type, the keyof operator only returns keys common to all union constituents, and both null and undefined have no properties. So, if either null or undefined are constituents, the result will be never, even if the other constituents’ keys can be enumerated.

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

This behavior poses a problem with how the type is currently written:

ts
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

We can jump this hurdle by removing null and undefined from the type using the built-in helper NonNullable<>

ts
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

Handling Union Types

What if we want to resolve a path in a union type where the joined types don’t all have the property we want to access?

ts
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`
 

As previously stated, when representing the keys of a union type, the keyof operator only returns keys common to all constituents. Since this actually ends up disallowing valid input for our use case, we need another approach.

When executed against a union type, conditional types in TypeScript are distributive, meaning the condition is executed across all constituents individually. We can use this behavior to write a type that gives all keys for every constituent instead of only those keys common to every constituent.

ts
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"
 

This distributive behavior means that Target in our conditional branch will only refer to a single constituent at a time, rather than all of them together. The results of the conditional are joined together to form the final type.

Now that we have a distributed keyof type, we also need a distributed access type, because if we encounter a target of type User | Post, we’ll only be able to access common properties by default.

ts
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'.2339Property '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
 

We can handle union types by replacing index accesses on Target with DistributedAccess, and replacing keyof Target with DistributedKeyof<Target>.

ts
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

Path Validation

By taking advantage of TypeScript’s powerful inference features, we can use conditional types to alter the path parameter’s type depending on the string literal being passed as the argument. This lets us validate that the string is a valid path lazily using our existing ResolvePath type, and also create a slick autocomplete experience.

The scenarios we want to handle are:

  1. Empty input
    • This should result in a type error, but we should return top-level properties
  2. Input ends with a period
    • This should result in a type error, but we should return possibilities for the next segment of the path
  3. Input otherwise does not reference a valid path
    • This should result in a type error
  4. Input references a valid path
    • This should NOT result in a type error
ts
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;
 

The best part about this approach is that size of the target type doesn’t affect ResolvePath’s recursion depth at all, so this will work just as well against massive types as it does against tiny types. This scalability is the quality that makes it “production-ready.”


Constructing the new call signature

Now that our building blocks, ResolvePath and ValidPath, are complete, we can use them to write our new call signature.

ts
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;

The Default Value

The defaultValue parameter has its own type parameter, DefaultValue, which intentionally isn’t being constrained by an extends clause. This is to allow a default value that doesn’t match the property type located at the path.

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

The return type also doesn’t include the default value’s type unless the path evaluates to a possibly undefined property. One could also argue that passing a default value for a property that cannot be undefined amounts to dead code, and should be disallowed by the type definition. This behavior is trivial to implement with a conditional rest parameter type.

ts
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;

Sweet, Sweet Auto-complete 🚀

Let’s check out the fruit of our labor. The return type is inferred correctly, invalid paths result in type errors, and the autocomplete experience we ended up with feels so natural that I almost forget that I’m typing out a string literal.

IDE experience demo

Full Code

ts
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 'string' is not assignable to parameter of type 'never'.2345Argument of type 'string' is not assignable to parameter of type 'never'.
 

Path Validation: The Dumb Approach 💩

In TypeScript, the typical method for asserting that a string literal is a valid value is to define or produce a union of string or template literals and let TypeScript check for assignability. In cases where we can reasonably control the size of that union, this method works fine. For validating path strings, this is a terrible idea. But just for fun, and as jcalz so eloquently puts it, “let’s do it first and scold ourselves later.”

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

The first thing we need to do is enumerate the keys of Target:

ts
type ValidPath<Target> = {
[Key in keyof Target]: Key;
}[keyof Target];
 
type paths = ValidPath<{ a: string; b: number; c: boolean; }>;
type paths = "a" | "b" | "c"

This example combines two concepts: mapped types and indexed access types. We declare a mapped type with the same set of keys as the type parameter Target to compute the values on a per-key basis. We then index that mapped type immediately using keyof Target to produce a union of those values.

To add a recursion to this type, we need a base case, or termination condition. If we keep recursing, Target[Key] will eventually evaluate to never, so the termination condition we’ll start with is Target extends never.

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

Instead of our value being only Key, now we’re joining it with a template literal type, ${Key}.${ValidPath<Target[Key]>}, which contains the recursion.

Another thing to note is that since we’re putting Key in a template literal type, we need to narrow its type to just types assignable to string using an intersection type (string & keyof Target), or else the type checker will complain.

Special case #1: Primitives

Running this type on some test data, a problem is immediately evident: paths to all of the prototype methods on primitive types, such as number and string, or built-ins such as Date, were being included in the resulting union type.

ts
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"

While this is technically valid input for get(), we must keep this union type as small as possible. Imagine if there were several dozen or even hundreds of string properties on the object type passed to get(). Every string property would add 48 paths. This gets out of hand very quickly even with only a handful of properties:

ts
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" | ... 172 more ... | "e.length.toLocaleString"

Even if that wasn’t a concern, I don’t see a practical use case for this. The prototype function returned from that get() call would lack a this binding, so we’d need to use .bind()(), .call(), or .apply() to call it anyways. I can only imagine the other horrors hidden in a codebase that uses get() for this purpose:

ts
_.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.

A naive approach might be to simply not recurse when the property value is a function.

ts
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"
 

Since that’s an unfortunate tradeoff, we can just list a few types to stop recursing at instead:

ts
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"

Special case #2a: Bracketed Array Access

ts
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, we need to change the recursion. We’ll add a conditional type to check if we’re dealing with an array.

ts
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];

Here, we’re using the infer keyword again to pluck the type of the array’s items into a new type parameter which we’ll call ArrayItem, so that we can then recurse on the type of the array’s items instead of the array type itself.

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

Special case #2b: Dotted Array Access

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

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

So we should just add that syntax to our union, right?

ts
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];

Wrong! This adds a second recursion in the same conditional branch, which causes our union type to grow exponentially larger for every level of array nesting. This union type is already potentially massive - we don’t need to throw exponential growth into the mix too. I know I’ve called this entire approach terrible already but we can at least try to minimize the terribleness.

ts
type depth1 = ValidPath<{ arr0: number[]; }>;
type depth1 = "arr0" | `arr0[${number}]` | `arr0.${number}`
type depth2 = ValidPath<{ arr0: { arr1: {}[] }[]; }>;
type depth2 = "arr0" | `arr0[${number}]` | `arr0.${number}` | `arr0[${number}].arr1` | `arr0[${number}].arr1[${number}]` | `arr0[${number}].arr1.${number}` | `arr0.${number}.arr1` | `arr0.${number}.arr1[${number}]` | `arr0.${number}.arr1.${number}`
type depth3 = ValidPath<{ arr0: { arr1: { arr2: {}[] }[] }[]; }>;
type depth3 = "arr0" | `arr0[${number}]` | `arr0.${number}` | `arr0[${number}].arr1` | `arr0[${number}].arr1[${number}]` | `arr0[${number}].arr1.${number}` | `arr0.${number}.arr1` | `arr0.${number}.arr1[${number}]` | `arr0.${number}.arr1.${number}` | `arr0[${number}].arr1[${number}].arr2` | `arr0[${number}].arr1[${number}].arr2[${number}]` | `arr0[${number}].arr1[${number}].arr2.${number}` | ... 8 more ... | `arr0.${number}.arr1.${number}.arr2.${number}`
type depth8 = ValidPath<{ arr0: { arr1: { arr2: { arr3: { arr4: { arr5: { arr6: { arr7: { arr8: {}[] }[] }[] }[] }[] }[] }[] }[] }[]; }>;
type depth8 = "arr0" | `arr0[${number}]` | `arr0.${number}` | `arr0[${number}].arr1` | `arr0[${number}].arr1[${number}]` | `arr0[${number}].arr1.${number}` | `arr0.${number}.arr1` | `arr0.${number}.arr1[${number}]` | `arr0.${number}.arr1.${number}` | `arr0[${number}].arr1[${number}].arr2` | `arr0[${number}].arr1[${number}].arr2[${number}]` | `arr0[${number}].arr1[${number}].arr2.${number}` | ... 1520 more ... | `arr0.${number}.arr1.${number}.arr2.${number}.arr3.${number}.arr4.${number}.arr5.${number}.arr6.${number}.arr7.${number}.arr8.${number}`
 

See how TypeScript computes every permutation of array access syntaxes for nested arrays when we do that?

In reality, we will probably just choose one style instead of mixing and matching like someArray[0].nestedArray.5.someProperty. While it’s true that get() does support using both syntaxes in the same path at runtime, it’s reasonable to choose one or the other to keep the union size smaller.

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

ts
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];

Here we add a new generic called ArrayAccessSyntax, where the syntax kind can be specified. The resulting union size is much more reasonable now that it grows linearly in this case.

ts
type depth1 = ValidPath<{ arr0: number[]; }>;
type depth1 = "arr0" | `arr0.${number}`
type depth2 = ValidPath<{ arr0: { arr1: {}[] }[]; }>;
type depth2 = "arr0" | `arr0.${number}` | `arr0.${number}.arr1` | `arr0.${number}.arr1.${number}`
type depth3 = ValidPath<{ arr0: { arr1: { arr2: {}[] }[] }[]; }>;
type depth3 = "arr0" | `arr0.${number}` | `arr0.${number}.arr1` | `arr0.${number}.arr1.${number}` | `arr0.${number}.arr1.${number}.arr2` | `arr0.${number}.arr1.${number}.arr2.${number}`
type depth8 = ValidPath<{ arr0: { arr1: { arr2: { arr3: { arr4: { arr5: { arr6: { arr7: { arr8: {}[] }[] }[] }[] }[] }[] }[] }[] }[]; }>;
type depth8 = "arr0" | `arr0.${number}` | `arr0.${number}.arr1` | `arr0.${number}.arr1.${number}` | `arr0.${number}.arr1.${number}.arr2` | `arr0.${number}.arr1.${number}.arr2.${number}` | `arr0.${number}.arr1.${number}.arr2.${number}.arr3` | `arr0.${number}.arr1.${number}.arr2.${number}.arr3.${number}` | `arr0.${number}.arr1.${number}.arr2.${number}.arr3.${number}.arr4` | `arr0.${number}.arr1.${number}.arr2.${number}.arr3.${number}.arr4.${number}` | ... 7 more ... | `arr0.${number}.arr1.${number}.arr2.${number}.arr3.${number}.arr4.${number}.arr5.${number}.arr6.${number}.arr7.${number}.arr8.${number}`
 

Special case #3: Circular types

Consider this example:

ts
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<...>}`); }'.2615Type 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<...>}`); }'.

Uh oh. In this case, ValidPath is infinitely recursive. The TypeScript compiler has a heuristic to detect infinite recursion which throws this "Type of property {Name} circularly references itself in mapped type" error. In other cases, the error message may be "Type instantiation is excessively deep or possibly infinite".

There are a couple ways to deal with this.

If possible, I prefer to figure out the nesting depth of the input type manually, and alter the type definitions so that they are non-circular. For instance, if we know that our User will be returned from an API that only populates a User’s friends array one level deep, we can rewrite the types so we’re explicit about the nesting depth.

ts
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`

Since there will be situations where it’s impractical to alter the types, such as when the type is an import from a 3rd party library, there’s a neat trick we can use as a fail-safe to limit recursion depth and prevent those errors:

ts
// 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];

The idea here is that every time ValidPath recurses, we pass DepthLimiter[Depth] as the Depth argument. DepthLimiter[10] evaluates to 9, then DepthLimiter[9] evaluates to 8, then DepthLimiter[8] evaluates to 7, etc, until DepthLimiter[0] evaluates to never, and the conditional expression Depth extends never ? never stops the type from recursing any further.

ts
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}`

Special case #4: Null / Undefined

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

ts
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" | ... 21 more ... | "friends.includes"

Now we have array prototype functions being listed instead. The problem here is that the conditional Target[Key] extends (infer ArrayItem)[] isn’t fulfilled when Target[Key] is possibly null or undefined, so the negative branch is evaluated instead, listing the array prototype functions. We need to exclude null and undefined from each property type using the built-in helper NonNullable<>.

ts
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];
 
ts
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`

Special Case #5: Union Types

In the previous section, I spent some time discussing how to handle union type inputs with distributive conditional types. We can re-use the DistributedKeyof and DistributedAccess types to handle union types properly.

ts
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"

Scolding Ourselves

It’s important to remember that recursive generic types can have a significant computation cost. Computing the entire set of valid values just to check if the input being passed is one of them is simply not a scalable solution for this use case.

For the demo-sized types I used in this blog post, the performance cost of computing the entire set of possible values is mostly negligible. However, for real-world data types with many properties and references to other objects, which may also have many properties and references, any of which might be circular, that set of all possible values can grow quite large and take an unacceptable amount of time and resources to compute. This can result in a degraded or non-functioning IDE experience where code completion popups take a noticeably long time to populate. It may cause the type checker to run out of memory and crash. If the union exceeds 100,000 members, it’ll throw an "Expression produces a union type that is too complex to represent" error instead.

While it’s a fun academic exercise, this type isn’t useful in practice due to its scalability issues.