Type-safe get function that extracts the value by paths in TypeScript
1type Get<O, P> = never; // implementation23type Step1 = Get<4 { article: { author: "Alexey Berezin"; keywords: ["typescript"] } },5 "article.keywords.0"6>;7type Step2 = Get<8 { author: "Alexey Berezin"; keywords: ["typescript"] },9 "keywords.0"10>;11type Step3 = Get<["typescript"], "0">;12type Step4 = Get<"typescript", "">;13type Result = "typescript";
Not a long time ago I discovered type-challenges for myself. Today I'll show not only the implementation of Get
, but also some common issues with the implementation, improvements and its usage in production.
If you want to learn TypeScript concepts first, please take a look at the Summary ⚓️
1. Basic implementation
As I said, there's a repo on GitHub: type-challenges. The current challenge is located in the "hard" category.
Here we work only with objects (as the solution doesn't require accessing arrays and tuples) and also we always can access object keys as they are defined in test cases.
So what should we start then from?
1.1. Getting keys
Imagine we solve the same challenge in JavaScript:
1const get = (obj, path) => {2 const keys = path.split(".");34 return keys.reduce((currentObj, key) => currentObj[key], obj);5};
Before calling keys.reduce
, we need to get a list of all keys. In JavaScript we can call path.split('.')
. In Typescript, we also need to get the keys from the path string.
Thankfully, since TypeScript 4.1, we have Template Literal types. We can infer the keys by removing dots.
We can use the Path
type to do so:
1type Path<T> = T extends `${infer Key}.${infer Rest}`2 ? [Key, ...Path<Rest>]3 : [];
It looks very simple and short. However once we write tests, we understand what was missed, as seen in the Playground validation. We forgot the case with only one single key left. Let's add it:
1type Path<T> = T extends `${infer Key}.${infer Rest}`2 ? [Key, ...Path<Rest>]3 : T extends `${infer Key}`4 ? [Key]5 : [];
To try it out, we can have a look at the Playground with tests cases.
1.2. Reducing the object
After having the keys, we can finally call keys.reduce
. To do so, we use another type GetWithArray
so we already know that keys are a string tuple:
1type GetWithArray<O, K> = K extends [infer Key, ...infer Rest]2 ? Key extends keyof O3 ? GetWithArray<O[Key], Rest>4 : never5 : never;
To explain it in more detail:
K extends [infer Key, ...infer Rest]
checks that we have at least one element in a tupleKey extends keyof O
lets us useO[Key]
to move recursively to the next step
Let's test it again on the Playground. We again forgot the last case with a tuple without elements. Let's add it:
1type GetWithArray<O, K> = K extends []2 ? O3 : K extends [infer Key, ...infer Rest]4 ? Key extends keyof O5 ? GetWithArray<O[Key], Rest>6 : never7 : never;
Final version with tests is available on Playground
1.3. All together
1type Get<O, P> = GetWithArray<O, Path<P>>;
Let's test it together to clarify everything's working as expected: Playground
Great, we did it ✅
2. Optional paths
When we work with real data in production, we don't always know if the data is valid or not. In this case we have optional paths all over the project.
Let's add test cases with optional paths and see what happens in the Playground.
Optional paths are not working properly. The reason behind it is simple. Let's go through one example to find the problem:
1type ProductionObject = {2 description?: string;3 title: string;4 date: string;5 author?: {6 name: string;7 location?: {8 city: string;9 };10 };11};1213type Step1 = Get<ProductionObject, "author.name">;14type Step2 = Get<15 | {16 name: string;17 location?:18 | {19 city: string;20 }21 | undefined;22 }23 | undefined,24 "name"25>;26type Step3 = Get<never, "">;27type Result = never; // 🔴 expected `string | undefined`
We cannot extract a key from an object if it can be undefined
or null
.
Let's fix it step by step:
2.1. Remove undefined, null or both
First, we declare 3 simple filters:
1type FilterUndefined<T> = T extends undefined ? never : T;23type FilterNull<T> = T extends null ? never : T;45type FilterUndefinedAndNull<T> = FilterUndefined<FilterNull<T>>;
We detect if undefined
or/and null
exist within the union type and, if so, delete it from the union. At the end we work only with the rest.
You can find the test cases again on the Playground
2.2. Modify reducer
Remember what we did for the type challenge. Let's extend our solution to support optional paths:
1type GetWithArray<O, K> = K extends []2 ? O3 : K extends [infer Key, ...infer Rest]4 ? Key extends keyof O5 ? GetWithArray<O[Key], Rest>6 : never // <= let's update this branch 🔄7 : never;
- We need to make sure the key exists in the keys of an optional object
- Otherwise, we assume it doesn't exist
1type GetWithArray<O, K> = K extends []2 ? O3 : K extends [infer Key, ...infer Rest]4 ? Key extends keyof O5 ? GetWithArray<O[Key], Rest>6 : Key extends keyof FilterUndefinedAndNull<O>7 ? GetWithArray<FilterUndefinedAndNull<O>[Key], Rest> | undefined8 : undefined9 : never;
Let's add tests and check if it's working in the Playground
Good job ✅
3. Accessing arrays and tuples
The next desired step for us is to support arrays and tuples:
1type ProductionObject = {2 posts: {3 title: string;4 description?: string;5 poster?: string;6 html: string;7 }[];8};910type Step1 = Get<ProductionObject, "posts.0">;11type Step2 = Get<12 {13 title: string;14 description?: string;15 poster?: string;16 html: string;17 }[],18 "0"19>;20type Step3 = Get<undefined, "">;21type Result = undefined; // 🔴 expected `{ title: string; ... html: string; }`
In JavaScript it would look like this:
1const get = (arr, path) => {2 const keys = path.split(".");34 return keys.reduce((currentArr, key) => currentArr[key], arr);5};
Here, a key can be either a string
or a number
. We already know how to get the keys with Path
:
1type Path<T> = T extends `${infer Key}.${infer Rest}`2 ? [Key, ...Path<Rest>]3 : T extends `${infer Key}`4 ? [Key]5 : [];
3.1. Reducing arrays
As for objects, we can similarly call keys.reduce
for arrays. To do so, we will implement the same type GetWithArray
but for arrays and then combine two solutions of GetWithArray
in one.
First, let's take a basic implementation for objects and adapt it for arrays. We use A
instead of O
for semantic reasons:
1type GetWithArray<A, K> = K extends []2 ? A3 : K extends [infer Key, ...infer Rest]4 ? Key extends keyof A5 ? GetWithArray<A[Key], Rest>6 : never7 : never;
After testing in the Playground, we found several gaps:
- Arrays cannot have a
string
key:
1type Step1 = GetWithArray<string[], "1">;2type Step2 = "1" extends keyof string[] ? string[]["1"] : never;3type Result = never; // 🔴 expected `string | undefined`
Here '1' extends keyof string[]
is false
therefore it returns never
.
- Similarly for readonly arrays:
1type Step1 = GetWithArray<readonly string[], "1">;2type Step2 = "1" extends keyof (readonly string[])3 ? (readonly string[])["1"]4 : never;5type Result = never; // 🔴 expected `string | undefined`
- Tuples (e.g.
[0, 1, 2]
) returnnever
instead ofundefined
:
1type Step1 = GetWithArray<[0, 1, 2], "3">;2type Step2 = "3" extends keyof [0, 1, 2] ? [0, 1, 2]["3"] : never;3type Result = never; // 🔴 expected `undefined`
Let's fix that as well! 🚀
3.2. Infer T | undefined
1type GetWithArray<A, K> = K extends []2 ? A3 : K extends [infer Key, ...infer Rest]4 ? Key extends keyof A5 ? GetWithArray<A[Key], Rest>6 : never // <= let's update this branch 🔄7 : never;
For arrays we want to get T | undefined
, depending on the values inside the array. Let's infer that value:
1type GetWithArray<A, K> = K extends []2 ? A3 : K extends [infer Key, ...infer Rest]4 ? Key extends keyof A5 ? GetWithArray<A[Key], Rest>6 : A extends readonly (infer T)[]7 ? GetWithArray<T | undefined, Rest>8 : never9 : never;
We added A extends readonly (infer T)[]
as all arrays extend readonly arrays.
Only need to fix the final case with tuples. Please check the tests in the Playground.
3.3. Tuples
At the moment, if we try to extract value by non-existing index from tuples, we will get the following:
1type Step1 = GetWithArray<[0, 1, 2], "3">;2type Step2 = "3" extends keyof [0, 1, 2] // <= false3 ? [0, 1, 2]["3"]4 : [0, 1, 2] extends (infer T)[] // <= true5 ? T | undefined6 : [0, 1, 2] extends readonly (infer T)[]7 ? T | undefined8 : never;910type Result = 0 | 1 | 2 | undefined; // 🔴 expected `undefined`
To fix it, we need to distinguish tuples from arrays. Let's use ExtendsTable
to find correct condition:
1type ExtendsTableRow<T extends any[], E extends any> = {2 [K in keyof T]: E extends T[K] ? true : false;3};45type ExtendsTable<T extends any[]> = {6 [K in keyof T]: ExtendsTableRow<T, T[K]>;7};
Let's use it for different types:
[0]
number[]
readonly number[]
any[]
1type Result = ExtendsTable<[[0], number[], readonly number[], any[]]>;
Let me create the table to clarify what is located inside Result
:
[0] | number[] | readonly number[] | any[] | |
---|---|---|---|---|
[0] | ✅ | ✅ | ✅ | ✅ |
number[] | ❌ | ✅ | ✅ | ✅ |
readonly number[] | ❌ | ❌ | ✅ | ❌ |
any[] | ❌ | ✅ | ✅ | ✅ |
We just created the table of extends
for TypeScript types.
If you see ✅ for the row and the column, it means the row type extends the column type. Several examples:
[0] extends [0]
number[] extends readonly number[]
On the other hand if it's ❌, the row type doesn't extend the column type. More examples:
number[] extends [0]
readonly number[] extends number[]
Let's take a closer look at row any[]
: for column [0]
it's ❌, but for other types it's ✅
This is actually an answer! 🔥🔥🔥
Let's rewrite GetWithArray
using the condition any[] extends A
:
1type GetWithArray<A, K> = K extends []2 ? A3 : K extends [infer Key, ...infer Rest]4 ? any[] extends A5 ? // arrays6 A extends readonly (infer T)[]7 ? GetWithArray<T | undefined, Rest>8 : undefined9 : // tuples10 Key extends keyof A11 ? GetWithArray<A[Key], Rest>12 : undefined13 : never;
- We distinguish arrays from tuples using
any[] extends A
- For arrays we infer
T | undefined
- For tuples, we extract their value if index exists
- Otherwise, we return
undefined
If you want to see it all in one place, don't forget to check out the Playground ✅
4. One solution
Now we have 2 solutions:
- For objects
1type GetWithArray<O, K> = K extends []2 ? O3 : K extends [infer Key, ...infer Rest]4 ? Key extends keyof O5 ? GetWithArray<O[Key], Rest>6 : Key extends keyof FilterUndefinedAndNull<O>7 ? GetWithArray<FilterUndefinedAndNull<O>[Key], Rest> | undefined8 : undefined9 : never;
- For arrays and tuples
1type GetWithArray<A, K> = K extends []2 ? A3 : K extends [infer Key, ...infer Rest]4 ? any[] extends A5 ? // arrays6 A extends readonly (infer T)[]7 ? GetWithArray<T | undefined, Rest>8 : undefined9 : // tuples10 Key extends keyof A11 ? GetWithArray<A[Key], Rest>12 : undefined13 : never;
Let's move the details of the implementation to functions ExtractFromObject
and ExtractFromArray
:
1type ExtractFromObject<2 O extends Record<PropertyKey, unknown>,3 K4> = K extends keyof O5 ? O[K]6 : K extends keyof FilterUndefinedAndNull<O>7 ? FilterUndefinedAndNull<O>[K] | undefined8 : undefined;
1type ExtractFromArray<A extends readonly any[], K> = any[] extends A2 ? A extends readonly (infer T)[]3 ? T | undefined4 : undefined5 : K extends keyof A6 ? A[K]7 : undefined;
As you can see, we’ve added restrictions to both functions:
ExtractFromObject
hasO extends Record<PropertyKey, unknown>
. It means that it accepts only general objectsExtractFromArray
similarly hasA extends readonly any[]
, which means that it accepts only general arrays and tuples
This helps to distinguish cases and avoid mistakes while passing types. Let's reuse them in GetWithArray
:
1type GetWithArray<O, K> = K extends []2 ? O3 : K extends [infer Key, ...infer Rest]4 ? O extends Record<PropertyKey, unknown>5 ? GetWithArray<ExtractFromObject<O, Key>, Rest>6 : O extends readonly any[]7 ? GetWithArray<ExtractFromArray<O, Key>, Rest>8 : undefined9 : never;
I covered this refactoring with tests. Another Playground is waiting for you 🚀.
5. Binding to JavaScript
Let's return to the solution on the JavaScript:
1const get = (obj, path) => {2 const keys = path.split(".");34 return keys.reduce((currentObj, key) => currentObj[key], obj);5};
At the moment we use lodash
in our project, e.g. function get
. If you check common/object.d.ts in @types/lodash
, you'll see that it's quite straightforward. The get
call in playground returns any
for most of the cases: typescript-lodash-types
Let's replace reduce
with any for
loop (e.g. for-of
) to have early exit in case value
is undefined
or null
:
1export function get = (obj, path) => {2 const keys = path.split(".");34 let currentObj = obj;5 for (const key of keys) {6 const value = currentObj[key];7 if (value === undefined || value === null) {8 return undefined;9 }1011 currentObj = value;12 }1314 return currentObj;15}
Let's try to cover the get
function with types we just wrote. Let's divide it into 2 cases:
Get
type can be used iff (if and only if) all the restrictions can be applied and the type is correctly inferred- A Fallback type is applied iff the validation is not passed (e.g. we pass
number
but expectedstring
inpath
)
To have 2 type overloads we need to use function
:
1export function get(obj: Record<string, unknown>, path: string): unknown {2 const keys = path.split(".");34 let currentObj = obj;5 for (const key of keys) {6 const value = currentObj[key];7 if (value === undefined || value === null) {8 return undefined;9 }1011 currentObj = value as Record<string, unknown>;12 }1314 return currentObj;15}
The implementation is ready ✅
But we still need to use our Get
type, let's add it:
1// strict types 🔎2export function get<O, K extends string>(obj: O, path: K): Get<O, K>;3// fallback 😌4export function get(obj: Record<string, unknown>, path: string): unknown {5 const keys = path.split(".");67 let currentObj = obj;8 for (const key of keys) {9 const value = currentObj[key];10 if (value === undefined || value === null) {11 return undefined;12 }1314 currentObj = value as Record<string, unknown>;15 }1617 return currentObj;18}
Please check the final solution in Codesandbox 📦:
- We added the implementation of get with types 🔥
- We covered the types with tests 🧪
- We covered the get function with tests 🧪
Summary
To solve the challenge we needed to know several TypeScript concepts:
- Tuples were introduced in TypeScript 1.3, but Variadic Tuple Types were only released in TypeScript 4.0 so we can use spread inside them:
1type Digits = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];23type Name =4 | [first: string, last: string]5 | [first: string, middle: string, last: string];
1type AnyArray = readonly any[];23type Merge<T extends AnyArray, U extends AnyArray> = [...T, ...U];45type Result = Merge<[1, 2], [3, 4]>;6// ^? [1, 2, 3, 4] 🟢
- Conditional types which were introduced in TypeScript 2.8
1type FilterUndefined<T> = T extends undefined ? never : T;
- Infer keyword in conditional types which was also introduced in TypeScript 2.8
1type AnyFunction = (...args: any) => any;23type MyParameters<TFunction extends AnyFunction> = TFunction extends (4 ...args: infer TParameters5) => any6 ? TParameters7 : never;
- Recursive conditional types, which were introduced in TypeScript 4.1
1type ElementType<T> = T extends ReadonlyArray<infer U> ? ElementType<U> : T;
- Template Literal types, which were also introduced in TypeScript 4.1
1type Fruit = "lemon" | "orange" | "apple";2type Quantity = 1 | 2 | 3;34type ShoppingList = `${Quantity} ${Fruit}`;5// ^? '1 lemon' | '1 orange' | '1 apple' | ... | '3 lemon' | '3 orange' | '3 apple'
1type Length<T extends { length: number }> = T["length"];
1function get<O, K extends string>(obj: O, path: K): Get<O, K>;2function get(obj: Record<string, unknown>, path: string): unknown {3 // body4}