Advanced Get
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:
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:
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:
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:
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:
Final version with tests is available on Playground
1.3. All together
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:
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:
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:
- We need to make sure the key exists in the keys of an optional object
- Otherwise, we assume it doesn't exist
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:
In JavaScript it would look like this:
Here, a key can be either a string
or a number
. We already know how to get the keys with Path
:
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:
After testing in the Playground, we found several gaps:
- Arrays cannot have a
string
key:
Here '1' extends keyof string[]
is false
therefore it returns never
.
- Similarly for readonly arrays:
- Tuples (e.g.
[0, 1, 2]
) returnnever
instead ofundefined
:
Let's fix that as well! 🚀
For arrays we want to get T | undefined
, depending on the values inside the array. Let's infer that value:
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:
To fix it, we need to distinguish tuples from arrays. Let's use ExtendsTable
to find correct condition:
Let's use it for different types:
[0]
number[]
readonly number[]
any[]
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
:
- 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
- For arrays and tuples
Let's move the details of the implementation to functions ExtractFromObject
and ExtractFromArray
:
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
:
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:
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
:
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
:
The implementation is ready ✅
But we still need to use our Get
type, let's add it:
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:
- Conditional types which were introduced in TypeScript 2.8
infer
keyword in conditional types which was also introduced in TypeScript 2.8
- Recursive conditional types, which were introduced in TypeScript 4.1
- Template Literal types, which were also introduced in TypeScript 4.1