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 undefinedValue = ResolvePath <{ a : number }, 'b'>;TryDeep 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 undefinedPath = ResolvePath <SomeType , 'a.b.d'>;TryThe 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.somePropertyorsomeArray[5].someProperty. - An index at the end of a path, such as
someArray.5orsomeArray[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 bracketed = ResolvePath <{ a : { b : { c : number }[] } }, 'a.b[0].c'>;TryNullish 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 B = keyof ({ a : string } | null);TryThat 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'>;TryUse 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 withNull = ResolvePath <{ a : { b : null | { c : number } } }, 'a.b.c'>;
type withOptional = ResolvePath <{ a : { b ?: { c : number } } }, 'a.b.c'>;TryUnion types
Union members don’t necessarily share every property:
type User = {
userId : string;
};
type Post = {
postId : string;
}
type OhNo = ResolvePath <User | Post , 'userId'>;// Since it's a string on `User` and not defined on `Post`, we want ResolvePath to return `string | undefined`
TryBecause 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 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 >;TryInside 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'.2339Property 'userId' does not exist on type 'User | Post'.
// `name` appears on both types, so this is valid
type WithNormalAccess2 = (User | Post )['name'];
// `userId` is a `string` on `User`, and not defined on `Post`, so it should be `string | undefined`
type WithDistributedAccess = DistributedAccess <User | Post , 'userId'>;TryReplace 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'>;TryPath 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:
- Empty input produces an error but suggests top-level properties.
- Input ending in a period produces an error but suggests the next segment.
- Any other invalid path produces an error.
- 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 ;
TryResolvePath 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 ;TryThe 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); // numberThe 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 ;TryAutocomplete
The result now has an inferred return type, errors for invalid paths, and autocomplete for each path segment:

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 streetAddressLength = _ .get (user , 'address.street.length');const favoriteFood = _ .get (user , 'favoriteFoods[0]');
// Invalid paths throw errors
const typo = _ .get (user , 'addddress.street' );Argument of type '"addddress.street"' is not assignable to parameter of type 'never'.2345Argument of type '"addddress.street"' is not assignable to parameter of type 'never'.TryAlternative 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; }>;TryThis 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 ];TryInstead 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 } } }>;TryAlthough 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; }>;TryThe 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 } } }>;
// but what if we have our own non-prototype function that we want to include?
type wrong = ValidPath <{ a : { b : { c : () => number } } }>;TryBecause 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 yay = ValidPath <{ a : { b : { c : () => number } } }>;TrySpecial 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 ];TryHere, 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 }[] } }>;TrySpecial 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 ];TryThat 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 depth2 = ValidPath <{ a : { b : {}[] }[]; }>;type depth3 = ValidPath <{ a : { b : { c : {}[] }[] }[]; }>;type depth8 = ValidPath <{ a : { b : { c : { d : { e : { f : { g : { h : { i : {}[] }[] }[] }[] }[] }[] }[] }[] }[]; }>;TryTypeScript 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 ];TryThe new ArrayAccessSyntax generic selects one style. With only one recursive branch, this part of the union grows linearly.
type depth1 = ValidPath <{ a : number[]; }>;type depth2 = ValidPath <{ a : { b : {}[] }[]; }>;type depth3 = ValidPath <{ a : { b : { c : {}[] }[] }[]; }>;type depth8 = ValidPath <{ a : { b : { c : { d : { e : { f : { g : { h : { i : {}[] }[] }[] }[] }[] }[] }[] }[] }[]; }>;TrySpecial 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<...>}`); }'.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<...>}`); }'.TryHere, 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 >;TryWhen 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 ];TryEach 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'>;TrySpecial 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 >;TryNow 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 >;TrySpecial 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 >;TryWhy 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.