Advanced Get
![Example of Get debugging](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_3840,q_75/v1633444777/blog_beraliv_dev/typed-get/get-usage-example.png)
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](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_3840,q_75/v1633444777/blog_beraliv_dev/typed-get/get-in-js-v1.png)
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](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_3840,q_75/v1633444777/blog_beraliv_dev/typed-get/path-v1.png)
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](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_3840,q_75/v1633444777/blog_beraliv_dev/typed-get/path-v2.png)
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](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_3840,q_75/v1633444777/blog_beraliv_dev/typed-get/get-with-array-v1.png)
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:
![GetWithArray for objects, version 2](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_1920,q_75/v1633444777/blog_beraliv_dev/typed-get/get-with-array-v2.png)
Final version with tests is available on Playground
1.3. All together
![Get for type challenge](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_1920,q_75/v1633444777/blog_beraliv_dev/typed-get/get-for-type-challenge.png)
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](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_1920,q_75/v1633444777/blog_beraliv_dev/typed-get/problem-1-with-get-for-type-challenge.png)
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](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_3840,q_75/v1633444777/blog_beraliv_dev/typed-get/filter-undefined-and-null.png)
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](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_1920,q_75/v1633444777/blog_beraliv_dev/typed-get/get-with-array-v2.5.png)
- We need to make sure the key exists in the keys of an optional object
- Otherwise, we assume it doesn't exist
![GetWithArray for objects, version 3](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_3840,q_75/v1633444777/blog_beraliv_dev/typed-get/get-with-array-v3.png)
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](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_1920,q_75/v1633444777/blog_beraliv_dev/typed-get/problem-2-with-get-for-type-challenge.png)
In JavaScript it would look like this:
![Get function for arrays in JS](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_1920,q_75/v1633444777/blog_beraliv_dev/typed-get/get-for-arrays-in-js.png)
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](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_3840,q_75/v1633444777/blog_beraliv_dev/typed-get/path-v2.png)
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](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_1920,q_75/v1633444777/blog_beraliv_dev/typed-get/get-with-array-for-arrays-v1.png)
After testing in the Playground, we found several gaps:
- Arrays cannot have a
string
key:
![Debugging normal arrays](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_3840,q_75/v1633444777/blog_beraliv_dev/typed-get/debug-get-with-array-for-arrays-v1-for-normal-string-array.png)
Here '1' extends keyof string[]
is false
therefore it returns never
.
- Similarly for readonly arrays:
![Debugging readonly arrays](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_2048,q_75/v1633444777/blog_beraliv_dev/typed-get/debug-get-with-array-for-arrays-v1-for-readonly-string-array.png)
- Tuples (e.g.
[0, 1, 2]
) returnnever
instead ofundefined
:
![Debugging tuples](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_3840,q_75/v1633444777/blog_beraliv_dev/typed-get/debug-get-with-array-for-arrays-v1-for-tuples.png)
Let's fix that as well! 🚀
![Extend GetWithArray solution for arrays](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_1920,q_75/v1633444777/blog_beraliv_dev/typed-get/get-with-array-for-arrays-v1.5.png)
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](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_1920,q_75/v1633444777/blog_beraliv_dev/typed-get/get-with-array-for-arrays-v2.png)
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](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_3840,q_75/v1633444777/blog_beraliv_dev/typed-get/debug-get-with-array-for-arrays-v2-for-tuples.png)
To fix it, we need to distinguish tuples from arrays. Let's use ExtendsTable
to find correct condition:
![ExtendsTable type function](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_3840,q_75/v1633444777/blog_beraliv_dev/typed-get/extends-table.png)
Let's use it for different types:
[0]
number[]
readonly number[]
any[]
![Use ExtendsTable with different types](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_3840,q_75/v1633444777/blog_beraliv_dev/typed-get/use-extends-table.png)
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](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_1920,q_75/v1633444777/blog_beraliv_dev/typed-get/get-with-array-for-arrays-v3.png)
- 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
![GetWithArray for objects](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_3840,q_75/v1633444777/blog_beraliv_dev/typed-get/get-with-array-v3.png)
- For arrays and tuples
![GetWithArray for arrays](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_1920,q_75/v1633444777/blog_beraliv_dev/typed-get/get-with-array-for-arrays-v3.png)
Let's move the details of the implementation to functions ExtractFromObject
and ExtractFromArray
:
![ExtractFromObject implementation](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_3840,q_75/v1633444777/blog_beraliv_dev/typed-get/extract-from-object.png)
![ExtractFromArray implementation](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_3840,q_75/v1633444777/blog_beraliv_dev/typed-get/extract-from-array.png)
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
:
![GetWithArray, refactored version](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_3840,q_75/v1633444777/blog_beraliv_dev/typed-get/get-with-array-v4.png)
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](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_3840,q_75/v1633444777/blog_beraliv_dev/typed-get/get-in-js-v1.png)
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](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_3840,q_75/v1633444777/blog_beraliv_dev/typed-get/get-in-js-v2.png)
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
:
![Get function with fallback types, version 3](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_3840,q_75/v1633444777/blog_beraliv_dev/typed-get/get-in-js-v3-fallback.png)
The implementation is ready ✅
But we still need to use our Get
type, let's add it:
![Get function, final version](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_3840,q_75/v1633444777/blog_beraliv_dev/typed-get/get-in-js-v4.png)
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:
![Example of a tuple](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_2048,q_75/v1633444777/blog_beraliv_dev/typed-get/tuples.png)
![Example of variadic tuple types](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_2048,q_75/v1633444777/blog_beraliv_dev/typed-get/variadic-tuple-types.png)
- Conditional types which were introduced in TypeScript 2.8
![Example of a conditional type](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_3840,q_75/v1633444777/blog_beraliv_dev/typed-get/conditional-types.png)
infer
keyword in conditional types which was also introduced in TypeScript 2.8
![Example of an infer keyword in conditional types](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_3840,q_75/v1633444777/blog_beraliv_dev/typed-get/infer-keyword-in-conditional-types.png)
- Recursive conditional types, which were introduced in TypeScript 4.1
![Example of recursive conditional types](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_3840,q_75/v1633444777/blog_beraliv_dev/typed-get/recursive-conditional-types.png)
- Template Literal types, which were also introduced in TypeScript 4.1
![Example of template literal types](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_1920,q_75/v1633444777/blog_beraliv_dev/typed-get/template-literal-types.png)
![Example of generic constrains](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_3840,q_75/v1633444777/blog_beraliv_dev/typed-get/example-of-generic-constrains.png)
![Example of function overloads](https://res.cloudinary.com/beraliv/image/upload/f_auto,w_1920,q_75/v1633444777/blog_beraliv_dev/typed-get/function-overloads.png)