beraliv

Type-safe get function that extracts the value by paths in TypeScript

Example of Get debugging
1type Get<O, P> = never; // implementation
2
3type 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:

Get function in JavaScript
1const get = (obj, path) => {
2 const keys = path.split(".");
3
4 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:

Path transforms a string into keys, version 1
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:

Path transforms a string into keys, final version
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:

GetWithArray for objects, version 1
1type GetWithArray<O, K> = K extends [infer Key, ...infer Rest]
2 ? Key extends keyof O
3 ? GetWithArray<O[Key], Rest>
4 : never
5 : never;

To explain it in more detail:

  1. K extends [infer Key, ...infer Rest] checks that we have at least one element in a tuple
  2. Key extends keyof O lets us use O[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:

GetWithArray for objects, version 2
1type GetWithArray<O, K> = K extends []
2 ? O
3 : K extends [infer Key, ...infer Rest]
4 ? Key extends keyof O
5 ? GetWithArray<O[Key], Rest>
6 : never
7 : never;

Final version with tests is available on Playground

1.3. All together

Get for type challenge
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:

Problem 1 with Get for type challenge
1type ProductionObject = {
2 description?: string;
3 title: string;
4 date: string;
5 author?: {
6 name: string;
7 location?: {
8 city: string;
9 };
10 };
11};
12
13type 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:

Filter undefined, null or both
1type FilterUndefined<T> = T extends undefined ? never : T;
2
3type FilterNull<T> = T extends null ? never : T;
4
5type 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:

Extend GetWithArray solution for objects
1type GetWithArray<O, K> = K extends []
2 ? O
3 : K extends [infer Key, ...infer Rest]
4 ? Key extends keyof O
5 ? GetWithArray<O[Key], Rest>
6 : never // <= let's update this branch 🔄
7 : never;
  1. We need to make sure the key exists in the keys of an optional object
  2. Otherwise, we assume it doesn't exist
GetWithArray for objects, version 3
1type GetWithArray<O, K> = K extends []
2 ? O
3 : K extends [infer Key, ...infer Rest]
4 ? Key extends keyof O
5 ? GetWithArray<O[Key], Rest>
6 : Key extends keyof FilterUndefinedAndNull<O>
7 ? GetWithArray<FilterUndefinedAndNull<O>[Key], Rest> | undefined
8 : undefined
9 : 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:

Problem 2 with Get for type challenge
1type ProductionObject = {
2 posts: {
3 title: string;
4 description?: string;
5 poster?: string;
6 html: string;
7 }[];
8};
9
10type 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:

Get function for arrays in JS
1const get = (arr, path) => {
2 const keys = path.split(".");
3
4 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:

Path transforms a string into keys
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:

GetWithArray for arrays, version 1
1type GetWithArray<A, K> = K extends []
2 ? A
3 : K extends [infer Key, ...infer Rest]
4 ? Key extends keyof A
5 ? GetWithArray<A[Key], Rest>
6 : never
7 : never;

After testing in the Playground, we found several gaps:

  1. Arrays cannot have a string key:
Debugging normal arrays
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.

  1. Similarly for readonly arrays:
Debugging 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`
  1. Tuples (e.g. [0, 1, 2]) return never instead of undefined:
Debugging tuples
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

Extend GetWithArray solution for arrays
1type GetWithArray<A, K> = K extends []
2 ? A
3 : K extends [infer Key, ...infer Rest]
4 ? Key extends keyof A
5 ? 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:

GetWithArray for arrays, version 2
1type GetWithArray<A, K> = K extends []
2 ? A
3 : K extends [infer Key, ...infer Rest]
4 ? Key extends keyof A
5 ? GetWithArray<A[Key], Rest>
6 : A extends readonly (infer T)[]
7 ? GetWithArray<T | undefined, Rest>
8 : never
9 : 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:

Debugging tuples
1type Step1 = GetWithArray<[0, 1, 2], "3">;
2type Step2 = "3" extends keyof [0, 1, 2] // <= false
3 ? [0, 1, 2]["3"]
4 : [0, 1, 2] extends (infer T)[] // <= true
5 ? T | undefined
6 : [0, 1, 2] extends readonly (infer T)[]
7 ? T | undefined
8 : never;
9
10type 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:

ExtendsTable type function
1type ExtendsTableRow<T extends any[], E extends any> = {
2 [K in keyof T]: E extends T[K] ? true : false;
3};
4
5type ExtendsTable<T extends any[]> = {
6 [K in keyof T]: ExtendsTableRow<T, T[K]>;
7};

Let's use it for different types:

  1. [0]
  2. number[]
  3. readonly number[]
  4. any[]
Use ExtendsTable with different types
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:

GetWithArray for arrays, final version
1type GetWithArray<A, K> = K extends []
2 ? A
3 : K extends [infer Key, ...infer Rest]
4 ? any[] extends A
5 ? // arrays
6 A extends readonly (infer T)[]
7 ? GetWithArray<T | undefined, Rest>
8 : undefined
9 : // tuples
10 Key extends keyof A
11 ? GetWithArray<A[Key], Rest>
12 : undefined
13 : never;
  1. We distinguish arrays from tuples using any[] extends A
  2. For arrays we infer T | undefined
  3. For tuples, we extract their value if index exists
  4. 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:

  1. For objects
GetWithArray for objects
1type GetWithArray<O, K> = K extends []
2 ? O
3 : K extends [infer Key, ...infer Rest]
4 ? Key extends keyof O
5 ? GetWithArray<O[Key], Rest>
6 : Key extends keyof FilterUndefinedAndNull<O>
7 ? GetWithArray<FilterUndefinedAndNull<O>[Key], Rest> | undefined
8 : undefined
9 : never;
  1. For arrays and tuples
GetWithArray for arrays
1type GetWithArray<A, K> = K extends []
2 ? A
3 : K extends [infer Key, ...infer Rest]
4 ? any[] extends A
5 ? // arrays
6 A extends readonly (infer T)[]
7 ? GetWithArray<T | undefined, Rest>
8 : undefined
9 : // tuples
10 Key extends keyof A
11 ? GetWithArray<A[Key], Rest>
12 : undefined
13 : never;

Let's move the details of the implementation to functions ExtractFromObject and ExtractFromArray:

ExtractFromObject implementation
1type ExtractFromObject<
2 O extends Record<PropertyKey, unknown>,
3 K
4> = K extends keyof O
5 ? O[K]
6 : K extends keyof FilterUndefinedAndNull<O>
7 ? FilterUndefinedAndNull<O>[K] | undefined
8 : undefined;
ExtractFromArray implementation
1type ExtractFromArray<A extends readonly any[], K> = any[] extends A
2 ? A extends readonly (infer T)[]
3 ? T | undefined
4 : undefined
5 : K extends keyof A
6 ? A[K]
7 : undefined;

As you can see, we’ve added restrictions to both functions:

  1. ExtractFromObject has O extends Record<PropertyKey, unknown>. It means that it accepts only general objects
  2. ExtractFromArray similarly has A 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:

GetWithArray, refactored version
1type GetWithArray<O, K> = K extends []
2 ? O
3 : 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 : undefined
9 : 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:

Get function in JavaScript
1const get = (obj, path) => {
2 const keys = path.split(".");
3
4 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:

Get function in JavaScript, version 2
1export function get = (obj, path) => {
2 const keys = path.split(".");
3
4 let currentObj = obj;
5 for (const key of keys) {
6 const value = currentObj[key];
7 if (value === undefined || value === null) {
8 return undefined;
9 }
10
11 currentObj = value;
12 }
13
14 return currentObj;
15}

Let's try to cover the get function with types we just wrote. Let's divide it into 2 cases:

  1. Get type can be used iff (if and only if) all the restrictions can be applied and the type is correctly inferred
  2. A Fallback type is applied iff the validation is not passed (e.g. we pass number but expected string in path)

To have 2 type overloads we need to use function:

Get function with fallback types, version 3
1export function get(obj: Record<string, unknown>, path: string): unknown {
2 const keys = path.split(".");
3
4 let currentObj = obj;
5 for (const key of keys) {
6 const value = currentObj[key];
7 if (value === undefined || value === null) {
8 return undefined;
9 }
10
11 currentObj = value as Record<string, unknown>;
12 }
13
14 return currentObj;
15}

The implementation is ready ✅

But we still need to use our Get type, let's add it:

Get function, final version
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(".");
6
7 let currentObj = obj;
8 for (const key of keys) {
9 const value = currentObj[key];
10 if (value === undefined || value === null) {
11 return undefined;
12 }
13
14 currentObj = value as Record<string, unknown>;
15 }
16
17 return currentObj;
18}

Please check the final solution in Codesandbox 📦:

  1. We added the implementation of get with types 🔥
  2. We covered the types with tests 🧪
  3. We covered the get function with tests 🧪

Summary

To solve the challenge we needed to know several TypeScript concepts:

  1. 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:
Example of tuples
1type Digits = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
2
3type Name =
4 | [first: string, last: string]
5 | [first: string, middle: string, last: string];
Example of variadic tuple types
1type AnyArray = readonly any[];
2
3type Merge<T extends AnyArray, U extends AnyArray> = [...T, ...U];
4
5type Result = Merge<[1, 2], [3, 4]>;
6// ^? [1, 2, 3, 4] 🟢
  1. Conditional types which were introduced in TypeScript 2.8
Example of a conditional type
1type FilterUndefined<T> = T extends undefined ? never : T;
  1. Infer keyword in conditional types which was also introduced in TypeScript 2.8
Example of an infer keyword in conditional types
1type AnyFunction = (...args: any) => any;
2
3type MyParameters<TFunction extends AnyFunction> = TFunction extends (
4 ...args: infer TParameters
5) => any
6 ? TParameters
7 : never;
  1. Recursive conditional types, which were introduced in TypeScript 4.1
Example of recursive conditional types
1type ElementType<T> = T extends ReadonlyArray<infer U> ? ElementType<U> : T;
  1. Template Literal types, which were also introduced in TypeScript 4.1
Example of template literal types
1type Fruit = "lemon" | "orange" | "apple";
2type Quantity = 1 | 2 | 3;
3
4type ShoppingList = `${Quantity} ${Fruit}`;
5// ^? '1 lemon' | '1 orange' | '1 apple' | ... | '3 lemon' | '3 orange' | '3 apple'
  1. Generic Constrains
Example of generic constrains
1type Length<T extends { length: number }> = T["length"];
  1. Function Overloads
Example of function overloads
1function get<O, K extends string>(obj: O, path: K): Get<O, K>;
2function get(obj: Record<string, unknown>, path: string): unknown {
3 // body
4}
typescriptjavascript

Comments

Alexey Berezin profile image

Written by Alexey Berezin who loves London 🏴󠁧󠁢󠁥󠁮󠁧󠁿, players ⏯ and TypeScript 🦺 Follow me on Twitter