beraliv

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

Example of Get debugging
Example of Get debugging

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
Get function in JavaScript

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

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

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
GetWithArray for objects, version 1

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
GetWithArray for objects, version 2

Final version with tests is available on Playground

1.3. All together

Get for type challenge
Get for type challenge

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
Problem 1 with Get for type challenge

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
Filter undefined, null or both

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
Extend GetWithArray solution for objects
  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
GetWithArray for objects, version 3

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
Problem 2 with Get for type challenge

In JavaScript it would look like this:

Get function for arrays in JS
Get function for arrays in JS

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

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
GetWithArray for arrays, version 1

After testing in the Playground, we found several gaps:

  1. Arrays cannot have a string key:
Debugging normal arrays
Debugging normal arrays

Here '1' extends keyof string[] is false therefore it returns never.

  1. Similarly for readonly arrays:
Debugging readonly arrays
Debugging readonly arrays
  1. Tuples (e.g. [0, 1, 2]) return never instead of undefined:
Debugging tuples
Debugging tuples

Let's fix that as well! 🚀

Extend GetWithArray solution for arrays
Extend GetWithArray solution for arrays

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
GetWithArray for arrays, version 2

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
Debugging tuples

To fix it, we need to distinguish tuples from arrays. Let's use ExtendsTable to find correct condition:

ExtendsTable type function
ExtendsTable type function

Let's use it for different types:

  1. [0]
  2. number[]
  3. readonly number[]
  4. any[]
Use ExtendsTable with different types
Use ExtendsTable with different types

Let me create the table to clarify what is located inside A:

[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
GetWithArray for arrays, final version
  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
GetWithArray for objects
  1. For arrays and tuples
GetWithArray for arrays
GetWithArray for arrays

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

ExtractFromObject implementation
ExtractFromObject implementation
ExtractFromArray implementation
ExtractFromArray implementation

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
GetWithArray, refactored version

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
Get function in Javascript

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
Get function in JavaScript, version 2

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
Get function with fallback types, version 3

The implementation is ready ✅

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

Get function, final version
Get function, final version

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 a tuple
Example of a tuple
Example of variadic tuple types
Example of variadic tuple types
  1. Conditional types which were introduced in TypeScript 2.8
Example of a conditional type
Example of a conditional type
  1. Infer keyword in conditional types which was also introduced in TypeScript 2.8
Example of an infer keyword in conditional types
Example of an infer keyword in conditional types
  1. Recursive conditional types, which were introduced in TypeScript 4.1
Example of recursive conditional types
Example of recursive conditional types
  1. Template Literal types, which were also introduced in TypeScript 4.1
Example of template literal types
Example of template literal types
  1. Generic Constrains
Example of generic constrains
Example of generic constrains
  1. Function Overloads
Example of function overloads
Example of function overloads
typescriptjavascript
Alexey Berezin profile image

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