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
typeResolvePath <Target ,Path extends string> =Path extends keyofTarget ?Target [Path ]: undefined;typevalue =ResolvePath <{a : number }, 'a'>;typeundefinedValue =ResolvePath <{a : number }, 'b'>;
Since we want to look up a deep property, we must make this type recursive.
ts
typeResolvePath <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 `${inferHead }.${inferRest }`?Head extends keyofTarget // 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 keyofTarget ?Target [Path ]// if not, this is an invalid path: undefined;typeSomeType = {a : {b : {c : number;}}};typevalue =ResolvePath <SomeType , 'a.b.c'>;typeundefinedPath =ResolvePath <SomeType , 'a.b.d'>;
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
typeArrayIndexAccess <Target ,PathPart ,> =// first, check for bracketed syntaxPathPart extends `${inferKey }[${number}]`?Key extends keyofTarget ?NonNullable <Target [Key ]> extends (inferArrayItem )[]?ArrayItem : never: never// if it's not bracketed syntax, maybe it's dotted numeric syntax:PathPart extends `${number}`?Target extends (inferArrayItem )[]?ArrayItem : never: never;typeResolvePath <Target ,Path extends string> =Path extends `${inferHead }.${inferRest }`?Head extends keyofTarget ?ResolvePath <NonNullable <Target [Head ]>,Rest >:ArrayIndexAccess <Target ,Head > extends inferArrayItem ?ResolvePath <ArrayItem ,Rest > | undefined: never:Path extends keyofTarget ?Target [Path ]:ArrayIndexAccess <Target ,Path > extends inferArrayItem ?ArrayItem | undefined: undefined;typedotted =ResolvePath <{a : {b : {c : number }[] } }, 'a.b.0.c'>;typebracketed =ResolvePath <{a : {b : {c : number }[] } }, 'a.b[0].c'>;
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
typeA = keyof ({a : string } | undefined);typeB = keyof ({a : string } | null);
This behavior poses a problem with how the type is currently written:
ts
typeResolvePath <Target ,Path extends string> =Path extends `${inferHead }.${inferRest }`?Head extends keyofTarget ?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 inferArrayItem ?ResolvePath <ArrayItem ,Rest > | undefined: undefined:Path extends keyofTarget ?Target [Path ]/// ^^^^^^^^^^^^:ArrayIndexAccess <Target ,Path > extends inferArrayItem ?ArrayItem | undefined: undefined;// in this example, `b` is an optional propertytypevalue =ResolvePath <{a : {b ?: {c : number } } }, 'a.b.c'>;
We can jump this hurdle by removing null
and undefined
from the type using the built-in helper NonNullable<>
ts
typeResolvePath <Target ,Path extends string> =Path extends `${inferHead }.${inferRest }`?Head extends keyofTarget ? |ResolvePath <NonNullable <Target [Head ]>,Rest >/// ^^^^^^^^^^^^^^^^^^^^^^^^^|Extract <Target [Head ], undefined>| (null extendsTarget [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 inferArrayItem ?ResolvePath <ArrayItem ,Rest > | undefined: undefined:Path extends keyofTarget ?Target [Path ]:ArrayIndexAccess <Target ,Path > extends inferArrayItem ?ArrayItem | undefined: undefined;typevalue =ResolvePath <{a : {b : {c : number } } }, 'a.b.c'>;typewithNull =ResolvePath <{a : {b : null | {c : number } } }, 'a.b.c'>;typewithOptional =ResolvePath <{a : {b ?: {c : number } } }, 'a.b.c'>;
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
typeUser = {userId : string;};typePost = {postId : string;}typeOhNo =ResolvePath <User |Post , 'userId'>;// 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
typeUser = {name : string;userId : string;};typePost = {name : string;postId : string;}typeWithNormalKeyof = keyof (User |Post );typeDistributedKeyof <Target > =Target extends any? keyofTarget : never;// The distribution results in the following type being equivalent to `keyof User | keyof Post`typeWithDistributedKeyof =DistributedKeyof <User |Post >;
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
typeUser = {name : string;userId : string;};typePost = {name : string;postId : string;}typeDistributedAccess <Target ,Key > =Target extends any?Key extends keyofTarget ?Target [Key ]: undefined: never;// Because `userId` does not appear in both User and Post, we get an errortypeProperty 'userId' does not exist on type 'User | Post'.2339Property 'userId' does not exist on type 'User | Post'.WithNormalAccess1 = (User |Post )['userId' ];// `name` appears on both types, so this is validtypeWithNormalAccess2 = (User |Post )['name'];// `userId` is a `string` on `User`, and not defined on `Post`, so it should be `string | undefined`typeWithDistributedAccess =DistributedAccess <User |Post , 'userId'>;
We can handle union types by replacing index accesses on Target
with DistributedAccess
, and replacing keyof Target
with DistributedKeyof<Target>
.
ts
typeDistributedKeyof <Target > =Target extends any ? keyofTarget : never;typeDistributedAccess <Target ,Key > =Target extends any?Key extends keyofTarget ?Target [Key ]: undefined: never;typeArrayIndexAccess <Target ,PathPart ,> =PathPart extends `${inferKey }[${number}]`?Key extendsDistributedKeyof <Target >?NonNullable <DistributedAccess <Target ,Key >> extends (inferArrayItem )[]?ArrayItem : never: never:PathPart extends `${number}`?Target extends (inferArrayItem )[]?ArrayItem : never: never;typeResolvePath <Target ,Path extends string> =Path extends `${inferHead }.${inferRest }`?Head extendsDistributedKeyof <Target >? |ResolvePath <NonNullable <DistributedAccess <Target ,Head >>,Rest >|Extract <DistributedAccess <Target ,Head >, undefined>| (null extendsDistributedAccess <Target ,Head > ? undefined : never):ArrayIndexAccess <Target ,Head > extends inferArrayItem ?ResolvePath <ArrayItem ,Rest > | undefined: undefined:Path extendsDistributedKeyof <Target >?DistributedAccess <Target ,Path >:ArrayIndexAccess <Target ,Path > extends inferArrayItem ?ArrayItem | undefined: undefined;typeUser = {userId : string;};typePost = {postId : string;}typeResult = {value :User |Post ;}typeCorrectUserId =ResolvePath <Result , 'value.userId'>;
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:
- Empty input
- This should result in a type error, but we should return top-level properties
- Input ends with a period
- This should result in a type error, but we should return possibilities for the next segment of the path
- Input otherwise does not reference a valid path
- This should result in a type error
- Input references a valid path
- This should NOT result in a type error
ts
typeValidPath <Target ,Input extends string> =// #1: empty inputInput 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 `${inferHead }.`?ResolvePath <Target ,Head > extends inferProp ?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 typeGet = <Target ,Path extends string,Property extendsResolvePath <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 typeGet = <Target ,Path extends string,Property extendsResolvePath <Target ,Path >,DefaultValue =Property >(target :Target ,path :ValidPath <Target ,Path >, ...args :undefined extendsProperty ? [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.
Full Code
ts
typeDistributedKeyof <Target > =Target extends any ? keyofTarget : never;typeDistributedAccess <Target ,Key > =Target extends any?Key extends keyofTarget ?Target [Key ]: undefined: never;typeArrayIndexAccess <Target ,PathPart ,> =PathPart extends `${inferKey }[${number}]`?Key extends string &DistributedKeyof <Target >?NonNullable <DistributedAccess <Target ,Key >> extends (inferArrayItem )[]?ArrayItem : never: never:PathPart extends `${number}`?Target extends (inferArrayItem )[]?ArrayItem : never: never;typeResolvePath <Target ,Path extends string> =Path extends `${inferHead }.${inferRest }`?Head extendsDistributedKeyof <Target >? |ResolvePath <NonNullable <DistributedAccess <Target ,Head >>,Rest >|Extract <DistributedAccess <Target ,Head >, undefined>| (null extendsDistributedAccess <Target ,Head > ? undefined : never):ArrayIndexAccess <Target ,Head > extends inferArrayItem ?ResolvePath <ArrayItem ,Rest > | undefined: undefined:Path extendsDistributedKeyof <Target >?DistributedAccess <Target ,Path >:ArrayIndexAccess <Target ,Path > extends inferArrayItem ?ArrayItem | undefined: undefined;typeValidPath <Target ,Input extends string> =Input extends ''?DistributedKeyof <Target >:Input extends `${inferHead }.`?ResolvePath <Target ,Head > extends inferProp ?Prop extends undefined? never: `${Head }.${string &DistributedKeyof <NonNullable <Prop >>}`: never:ResolvePath <Target ,Input > extends undefined? never:Input ;export typeGet = <Target ,Path extends string,Property extendsResolvePath <Target ,Path >,DefaultValue =Property >(target :Target ,path :ValidPath <Target ,Path >, ...args : (undefined extendsProperty ? [defaultValue ?:DefaultValue ] : [])) =>Property extends undefined?DefaultValue |NonNullable <Property >:Property ;// ----declare const_ : {get :Get ;}interfaceUser {id : string;name : {first : string;last : string;},address : {street : string;city : string;state : string;postalCode : string;},favoriteFoods :Array <{name : string;type : string;}>}declare constuser :User ;constname =_ .get (user , 'name')conststreetAddressLength =_ .get (user , 'address.street.length');constfavoriteFood =_ .get (user , 'favoriteFoods[0]');// Invalid paths throw errorsconstArgument of type 'string' is not assignable to parameter of type 'never'.2345Argument of type 'string' is not assignable to parameter of type 'never'.typo =_ .get (user ,'addddress.street' );
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
typeValidPath <Target > = {[Key in keyofTarget ]:Key ;}[keyofTarget ];typepaths =ValidPath <{a : string;b : number;c : boolean; }>;
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
typeValidPath <Target > =Target extends never? never: {[Key in string & keyofTarget ]:Key | `${Key }.${ValidPath <Target [Key ]>}`}[string & keyofTarget ];
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
typepaths =ValidPath <{a : {b : {c : number } } }>;
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
typepaths =ValidPath <{a : string;b : string;c :Date ;d : boolean;e : string; }>;
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
typeValidPath <Target > = {[Key in string & keyofTarget ]:Target [Key ] extendsFunction ? never:Key | `${Key }.${ValidPath <Target [Key ]>}`}[string & keyofTarget ];typepaths =ValidPath <{a : {b : {c : number } } }>;// but what if we have our own non-prototype function that we want to include?typewrong =ValidPath <{a : {b : {c : () => number } } }>;
Since that’s an unfortunate tradeoff, we can just list a few types to stop recursing at instead:
ts
typeLeaf =|Date | boolean| string| number| symbol| bigint;typeValidPath <Target > =Target extendsLeaf ? never: {[Key in string & keyofTarget ]:Key | `${Key }.${ValidPath <Target [Key ]>}`}[string & keyofTarget ];typepaths =ValidPath <{a : {b : {c : number } } }>;typeyay =ValidPath <{a : {b : {c : () => number } } }>;
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
typeValidPath <Target > =Target extends never? never:Target extendsLeaf ? never: {[Key in string & keyofTarget ]:Key | (Target [Key ] extends (inferArrayItem )[]? `${Key }[${number}]` | `${Key }[${number}].${ValidPath <ArrayItem >}`: `${Key }.${ValidPath <Target [Key ]>}`)}[string & keyofTarget ];
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
typepaths =ValidPath <{a : {b : {c : number }[] } }>;
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
typeValidPath <Target > =Target extends never? never:Target extendsLeaf ? never: {[Key in string & keyofTarget ]:Key | (Target [Key ] extends (inferArrayItem )[]? // bracket syntax| `${Key }[${number}]`| `${Key }[${number}].${ValidPath <ArrayItem >}`// dot syntax| `${Key }.${number}`| `${Key }.${number}.${ValidPath <ArrayItem >}`: `${Key }.${ValidPath <Target [Key ]>}`)}[string & keyofTarget ];
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
typedepth1 =ValidPath <{arr0 : number[]; }>;typedepth2 =ValidPath <{arr0 : {arr1 : {}[] }[]; }>;typedepth3 =ValidPath <{arr0 : {arr1 : {arr2 : {}[] }[] }[]; }>;typedepth8 =ValidPath <{arr0 : {arr1 : {arr2 : {arr3 : {arr4 : {arr5 : {arr6 : {arr7 : {arr8 : {}[] }[] }[] }[] }[] }[] }[] }[] }[]; }>;
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
typeValidPath <Target ,ArrayAccessSyntax extends 'brackets' | 'dot' = 'dot'> =Target extends never? never:Target extendsLeaf ? never: {[Key in string & keyofTarget ]:Key | (Target [Key ] extends (inferArrayItem )[]? {brackets : `${Key }[${number}]` | `${Key }[${number}].${ValidPath <ArrayItem ,ArrayAccessSyntax >}`;dot : `${Key }.${number}` | `${Key }.${number}.${ValidPath <ArrayItem ,ArrayAccessSyntax >}`;}[ArrayAccessSyntax ]: `${Key }.${ValidPath <Target [Key ],ArrayAccessSyntax >}`)}[string & keyofTarget ];
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
typedepth1 =ValidPath <{arr0 : number[]; }>;typedepth2 =ValidPath <{arr0 : {arr1 : {}[] }[]; }>;typedepth3 =ValidPath <{arr0 : {arr1 : {arr2 : {}[] }[] }[]; }>;typedepth8 =ValidPath <{arr0 : {arr1 : {arr2 : {arr3 : {arr4 : {arr5 : {arr6 : {arr7 : {arr8 : {}[] }[] }[] }[] }[] }[] }[] }[] }[]; }>;
Special case #3: Circular types
Consider this example:
ts
interfaceUser {id : string;name : string;friends :User []; // circular property}typeType 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<...>}`); }'.paths =ValidPath <User , 'dot'>;
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
typeUser = {id : string;name : string;};typeUserWithFriends =User & {friends :User [];}typepaths =ValidPath <UserWithFriends >;
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 variabletypeDepthLimiter = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];typeValidPath <Target ,ArrayAccessSyntax extends 'brackets' | 'dot' = 'dot',Depth extendsDepthLimiter [number] = 10> =Depth extends never? never:Target extends never? never:Target extendsLeaf ? never: {[Key in string & keyofTarget ]:Key | (Target [Key ] extends (inferArrayItem )[]? {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 & keyofTarget ];
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
interfaceUser {id : string;friends :User [];}typepaths =ValidPath <User , 'dot'>;
Special case #4: Null / Undefined
Returning to the above example, what happens if we make friends
optional?
ts
typeUser = {id : string;name : string;};typeUserWithFriends =User & {friends ?:User [];}typepaths =ValidPath <UserWithFriends >;
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
typeValidPath <Target ,ArrayAccessSyntax extends 'brackets' | 'dot' = 'dot',Depth extendsDepthLimiter [number] = 10> =Depth extends never? never:Target extends never? never:Target extendsLeaf ? never: {[Key in string & keyofTarget ]:Key | (// Target[Key] was replaced with NonNullable<Target[Key]>NonNullable <Target [Key ]> extends (inferArrayItem )[]? {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 & keyofTarget ];
ts
typeUser = {id : string;name : string;};typeUserWithFriends =User & {friends ?:User [];}typepaths =ValidPath <UserWithFriends >;
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
typeDistributedKeyof <Target > =Target extends any ? keyofTarget : never;typeDistributedAccess <Target ,Key > =Target extends any?Key extends keyofTarget ?Target [Key ]: undefined: never;typeLeaf =|Date | boolean| string| number| symbol| bigint;typeDepthLimiter = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];typeValidPath <Target ,ArrayAccessSyntax extends 'brackets' | 'dot' = 'dot',Depth extendsDepthLimiter [number] = 10> =Depth extends never? never:Target extends never? never:Target extendsLeaf ? never: {[Key in string &DistributedKeyof <Target >]:Key | (NonNullable <DistributedAccess <Target ,Key >> extends (inferArrayItem )[]? {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 >];// --------typeUser = {name : string;userId : string;};typePost = {name : string;postId : string;}typeResult = {value :User |Post ;};typepaths =ValidPath <Result >;
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.