TypeScript 4.1 was released not that long ago and it introduced many cool features. But the template literal types, in my opinion, were the biggest “game-changer”.
String literals, alongside with recursive conditional types, gave the ability to create fully typed get
and set
functions.
For those who don’t know, these functions were popularized by lodash
and are used to safely read/update properties inside objects (potentially deep):
// without `get` and `set`
const deepProp = obj.some && obj.some.deep && obj.some.deep.prop;
// or with optional chaining
const deepProp = obj.some?.deep?.prop;
if (obj.some && obj.some.deep && isObject(obj.some.deep)) {
obj.some.deep.prop = 'new value';
}
// with `get` and `set`
const deepProp = get(obj, 'some.deep.prop');
set(obj, 'some.deep.prop', 'new value');
So I decided to implement them in typescript with complete type safety and share it with you here.
There is a lot to explain, so this article will be separated into multiple parts (2 or 3).
stringToPath
Let’s start with a StringToPath
type.
What it’ll do is receive a string as a generic type, and split it by .
into a path array.
type StringToPath<
StringPath extends string,
Path extends string[] = []
> = StringPath extends `${infer Key}.${infer Rest}`
? StringToPath<Rest, [...Path, Key]>
: [...Path, StringPath];
As you can see we test if StringPath
contains a dot. If so, we infer
(resolve) the part before the dot and after it. The part before the dot (Key
) gets added to our path and we continue the process with the remaining part of the string (Rest
).
If StringPath
doesn’t contain the .
we just append it to the Path
and “return”.
Basically this type is the equivalent of the next function:
function stringToPath<T extends string>(
stringPath: T,
path: string[] = []
): string[] {
// match everything until dot, and everything
// after it
const match = stringPath.match(/^([^.]+)\.(.*)/);
if (!match) return [...path, stringPath];
const [, key, rest] = match;
return stringToPath(rest, [...path, key]);
}
So let’s test how our type is working:
type Test = StringToPath<'a.b.0.c'>; // ['a', 'b', '0', 'c']
As you can see, everything works great. However, we still have one problem. If I use a..c
as a path, the result will be ['a', '', 'c']
. Which isn’t correct.
We need to modify our StringToPath
, so it doesn’t adds empty strings to our path:
type StringToPath<
StringPath extends string,
Path extends string[] = []
> = StringPath extends `${infer Key}.${infer Rest}`
? StringToPath<Rest, AppendPath<Path, Key>>
: AppendPath<Path, StringPath>;
type AppendPath<
Path extends string[],
Item extends string
> = Item extends '' ? Path : [...Path, Item];
We created an AppendPath
type that will add new elements to the path. Inside of it, we’ll check that Item
isn’t empty before appending it.
Also, until we haven’t forgotten, let’s split StringToPath
into 2 separate types to make sure that the user won’t accidentally pass the second argument.
type StringToPath<StringPath extends string> = StringToPath_<StringPath>;
type StringToPath_<
StringPath extends string,
Path extends string[] = []
> = StringPath extends `${infer Key}.${infer Rest}`
? StringToPath_<Rest, AppendPath<Path, Key>>
: AppendPath<Path, StringPath>;
Now after we’ve finished with our StringToPath
type, we need to implement a function where we will use it:
const stringToPath = <T extends string>(path: T) =>
path.split('.').filter(Boolean) as StringToPath<T>;
Note that we use as
to explicitly tell TypeScript the return type. Otherwise, it’d be string[]
.
get
Now we are getting to a more interesting (and also difficult) part. After we have a way to get a correct path, we need to resolve this path from the object. Let’s start by creating a Get
type:
interface AnyObject {
[key: string]: any;
}
type Get<
Value extends AnyObject,
Path extends string,
Default = undefined
> = any;
It’ll take 3 generic arguments:
- Value - the object to get value from.
- Path - the path at which the value is located.
- Default - the default value to return in case the path isn’t valid (
undefined
by default)
What we going to do inside Get
is “iterate” over Path
and check whether the current key is present in the object. If so, we get the value of this key and repeat the process on it with the rest of the keys.
If at some point we figure out that key isn’t present in the current value - we’ll return a Default
.
I hope that you understood the algorithm. If not - don’t worry, you’ll get it when you see the code.
But for now, let’s abstract from our problem, and try to imagine how would we iterate over the path array.
It’s pretty easy to do in JavaScript (for, forEach, map, etc). However, you can’t use them for your types in TypeScript.
But what if we didn’t have these methods in JS? Is there any other way to repeat the same logic for the list of elements?
The answer is yes. We could do it with recursion.
Let’s look at these 2 examples:
function mapArray(arr: number[]) {
return arr.map(v => v * 2);
}
function mapArrayRecursively(
arr: number[],
result: number[] = [],
index = 0
): number[] {
// base case
if (arr.length === index) return result;
// map current element
const mappedElement = arr[index] * 2;
// append path, increment index, continue the process
return mapArrayRecursively(arr, [...result, mappedElement], index + 1);
}
The first one iterates over the array by using the builtin .map
method on the array.
The second one does the same thing, but with recursion. It starts from 0
and on each function call maps the element at current index
. Then adds the mapped element to the result
, increments index
, and calls itself again. Once index
reaches the array length, the function returns the result
.
You can see that both of these functions do the same thing but in a little different manner.
Of course, the first option is more declarative and more efficient. However, with types, we don’t have any choice but to use recursion.
Now once we understand how to recursively iterate over array, let’s come back to our Get
and try to imagine how we are going to do it here:
type Get<
Value extends AnyObject,
StringPath,
Default = undefined
> = Get_<Value, StringToPath<StringPath>, Default>;
type Get_<
Value,
Path extends string[],
Default = undefined,
Index extends number = 0
> = {
// brach 1
0: any;
// branch 2
1: Value extends undefined ? Default : Value;
}[Index extends Path['length'] ? 1 : 0]; // base check
First of all, we’ve added Get_
type that will receive the array path and have an additional argument Index
that always starts from 0
.
Note that Get_
doesn’t have Value extends AnyObject
, because Get
already ensures that Value
passed by a user is an object. And inside Get_
we could be dealing with any value since it’s a recursive type.
Inside the Get_
type we have 2 branches and a base check that ensures our Index
doesn’t exceed Path['length']
. The first brach is the one where the main logic for resolving the Path
will be. The second one is used to return resolved Value
, or Default
in case Value
is undefined
.
However, we still have one problem to solve before we could move to our main logic - incrementing Index
.
It’s easy to do in JS, but TS types don’t have mathematical operators.
Because of that, we need some other way to determine what index should be next. So I came up with the next solution:
type Sequence = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
// now if we want to get the next number
// we just access the index at current number
type Index = 0;
type Index2 = Sequence[Index]; // 1
type Index3 = Sequence[Index2]; // 2
I created an array where at index x
you have the value x + 1
and if you want to get the next Index
, just use Sequence[Index]
.
After we figured out all the details, it’s time to start implementing our logic.
interface AnyObject {
[key: string]: any;
}
type Sequence = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
type Get<
Value extends AnyObject,
StringPath extends string,
Default = undefined
> = Get_<Value, StringToPath<StringPath>, Default>;
type Get_<
Value,
Path extends string[],
Default = undefined,
Index extends number = 0
> = {
0: Value extends AnyObject // 1
? GetKey<Value, Path, Default, Index> // 2
: Default;
1: Value extends undefined ? Default : Value; // 5
}[Index extends Path['length'] ? 1 : 0];
type GetKey<
Value extends AnyObject,
Path extends string[],
Default,
Index extends number
> = Path[Index] extends keyof Value // 3
? Get_<Value[Path[Index]], Path, Default, Sequence[Index]>
: Default; // 4
Let’s go through the code above and understand what’s happening:
- We check that
Value
isn’t primitive by comparing it toAnyObject
, otherwise we returnDefault
. Also since we have conditional inside of our type, it forces TS to distribute theValue
(read more about type distribution at the end of the article), which means that our type will work correctly with unions. - If a value is an object, we call the
GetKey
type. - Inside the
GetKey
we check thatPath[Index]
is present in theValue
. If so, we recursively callGet
with a new value (Value[Path[Index]]
) and incremented index (Sequence[Index]
). Path[Index]
isn’t a key ofValue
. ReturnDefault
.- Once we iterated through the pass, we return
Value
or ifValue
isundefined
-Default
. Note that the conditionValue extends undefined
will also force ourValue
to distribute here, which will provide a correct result with unions.
Ok, great. Now we can run some tests and check that everything works correctly:
type TestObject = {
a: number;
b: {
c: 'hello';
};
};
type Basic = Get<TestObject, 'a'>; // number
type Nested = Get<TestObject, 'b.c'>; // 'hello'
type Default = Get<TestObject, 'h', 'default value'>; // 'default value'
type ReturnUndefinedIfNoDefault = Get<TestObject, 'h'>; // undefined
type WorksWithUnions = Get<{ a: number } | { a: string }, 'a'>; // string | number
type DefaultWorkCorrectlyWithUnions = Get<
{ a: number } | { b: string },
'a',
'default'
>; // 'default' | number
type WorksWithArrays = Get<['1', '2', '3'], '1'>; // '2'
We can see that our “tests” are passing. However, there is still a case that we haven’t covered:
type WorksNonTupleArrays = Get<number[], '2'>; // `undefined`
When we use Get
on regular(non tuple) arrays, literal indexes (e.g. 5
) return undefined
. This happens because literal indexes doesn’t exist as key in arrays:
type Test = number[]['5']; // error
// ^^^^^
// Property '5' does not exist on type 'number[]'.ts(2339)
Therefore, we have to add a case to handle regular arrays.
But before, let’s create few types that will help us to do it.
First one is a type that extracts array value:
type GetArrayValue<
T extends readonly unknown[]
> = T extends ReadonlyArray<infer U> ? U : never;
Also, we have to create a type to check that array is a tuple.
Actually, there are few ways check for tuples. But the most efficient one that I know is next:
type IsTuple<T extends readonly unknown[]> = number extends T['length'] // 2
? false
: true;
We check that number
is assignable(extends
) to the array’s length (T['length']
). You may say think that it doesn’t make sense, the length of the array is always number
. And it’d be correct. However, tuples have a literal length (e.g. 8
), it’s also number, but number
isn’t assignable to it:
const someFn = (arg: number) => {
const eight: 8 = arg;
// ^^^^^
// Type 'number' is not assignable to type '8'.ts(2322)
};
Knowing that we check that if the length of an array is literal (number
doesn’t extends
it) - it’s a tuple.
So now let’s update our GetKey
type to correctly handle arrays:
/* ... */
type IsNumericKey<T extends string> = T extends `${number}` ? true : false;
type GetKey<
Value extends AnyObject,
Path extends string[],
Default,
Index extends number
> = Path[Index] extends keyof Value
? Get_<Value[Path[Index]], Path, Default, Sequence[Index]>
: Value extends readonly unknown[] // 1
? IsNumericKey<Path[Index]> extends false // 2
? Default
: IsTuple<Value> extends true // 3
? Default
: Get_<
GetArrayValue<Value> | undefined, // 4
Path,
Default,
Sequence[Index]
>
: Default; // 5
- Instead of immediately returning
Default
we check that value is array since we know that they may not have literal indexes. - We know that the value is an array, but we have to make sure that the key that was accessed was numeric. Otherwise, it’d always be present in
keyof Value
and we wouldn’t come this far. Value
is an array andPath[Index]
is numeric, but there is still one thing that we have to assert. That theValue
isn’t tuple. If it is, returnDefault
.- All of the above conditions are met, we get the value of an array through
GetArrayValue
type and addundefined
to it because we can’t know for sure that this index exists on an array (similar to thenoUncheckIndexAccess
rule in tsconfig). And this value is passed toGet_
type with all other arguments and incrementedIndex
. - If one of the checks above fails, we return
Default
.
Now we can test that it works correctly with regular arrays:
type WorksNonTupleArrays = Get<number[], '2'>; // `number | undefined`
Let’s also test few more complex cases:
type Mixed = Get<{ a: [1, 2, { b: string[] }] }, 'a.2.b.10'>; // `string | undefined`
type MixedWithDefault = Get<{ a: [1, 2, { b: string[] }] }, 'a.2.b.10', null>; // `string | null`
type Unions = Get<{ a: ['a', 'b'] } | { a: { '1': number } }, 'a.1'>; // `'b' | number`
type UnionPath = Get<{ a: number; b: boolean }, 'a' | 'b'>;
type UnionPathDifferentLength = Get<
{ a: number; b: { c: boolean } },
'a' | 'b.c'
>; // 'number' | { c: boolean } - wrong
You can see that everything works as expected, except the last case.
Let’s try to understand why this happens:
type Test = StringToPath<'a' | 'b.c'>; // ['a'] | ['b', 'c']
When we call StringToPath
with a union, it returns the union of arrays. Which means that our type is correctly distributed.
If you don’t know, union provided to the generic type gets distributed when they are used in conditionals:
type IsString<T> = T extends string ? 'correct' : 'incorrect';
// distributed
type Test = IsString<string | number>; // 'correct' | 'incorrect';
// not distributed
type TestWithoutGeneric = string | number extends string
? 'correct'
: 'incorrect'; // 'incorrect'
You can see that when we provide union to generic T
, the conditional type will be applied to each item of these unions. But in the second example union gets treated as one type.
And coming back to our StringToPath
, we can see that it’s correctly distributed because generic StringPath
is used in the condition:
type StringToPath<
StringPath extends string
> = StringToPath_<StringPath>;
type StringToPath_<
StringPath extends string,
Path extends string[] = []
> =
// condition
StringPath extends `${infer Key}.${infer Rest}`
? StringToPath<Rest, AppendPath<Path, Key>>
: AppendPath<Path, StringPath>;
But after that, the union path gets passed to Get_
, and that is where the problem happens:
type Get_<
Value,
Path extends string[],
Default = undefined,
Index extends number = 0
> = {
0: Value extends AnyObject ? GetKey<Value, Path, Default, Index> : Default;
1: Value extends undefined ? Default : Value;
}[Index extends Path['length'] ? 1 : 0];
Although Path
is used in the condition (Index extends Path['length']
), it’s not getting distributed.
This happens because we compare Index
to Path['length']
, and Path['length']
isn’t a generic type, only Path
is. That’s actually half of the reasons why type distribution doesn’t work. But it’s the topic for another article.
However, the good thing is that we could intentionally “force” TypeScript to distribute the type:
// no distributed
type WrapIntoArray<T> = [T];
type Test = WrapIntoArray<string | number>; // [string | number]
// distributed
type WrapIntoArray<T> = T extends unknown ? [T] : never;
type Test = WrapIntoArray<string | number>; // [string] | [number]
In the second case, we just compared T
to unknown
, which means that everything will pass this condition except never
. So everything would work as expected, but because our generic is now used in the condition, it’ll get distributed when we pass union.
Knowing that we could update our Get
type, to distribute the union StringPath
and make sure that union Path
will never be passed to Get_
:
type Get<
Value extends AnyObject,
StringPath extends string,
Default = undefined
> = StringPath extends unknown
? // `StringPath` will never be union here
// therefore `Get_` will never receive
// union `Path`
Get_<Value, StringToPath<StringPath>, Default>
: never;
So we basically call the Get_
type for each item of the StringPath
union and get back the union of the results. We could express it in JS pseudo code like that:
distribute(StringPath).map(StringPath =>
Get_(Value, StringToPath(StringPath), Default)
);
Let’s make sure that our failed “test” is passing:
type UnionPathDifferentLength = Get<
{ a: number; b: { c: boolean } },
'a' | 'b.c'
>; // number | boolean
Great, now after we’ve finished with our Get
type, it’s time to implement the function:
const hasOwnProperty = Object.prototype.hasOwnProperty;
const hasOwn = <T extends AnyObject>(
obj: T,
key: PropertyKey
): key is keyof T => hasOwnProperty.call(obj, key);
const isObject = (value: unknown): value is AnyObject =>
typeof value === 'object' && value !== null;
const isUndefined = (value: unknown): value is undefined =>
typeof value === 'undefined';
interface GetFunction {
<Obj extends AnyObject, Path extends string, Default = undefined>(
object: Obj,
path: Path,
defaultValue?: Default
): Get<Obj, Path, Default>;
}
const get: GetFunction = (object, stringPath, defaultValue) => {
const path = stringToPath(stringPath);
let index = -1;
const lastIndex = path.length - 1;
while (++index <= lastIndex) {
const key = path[index]!;
if (hasOwn(object, key)) {
if (lastIndex === index) {
return isUndefined(object[key]) ? defaultValue : object[key];
}
if (isObject(object[key])) {
object = object[key];
}
} else {
return defaultValue;
}
}
return object;
};
I don’t think that there is anything to explain since we already wrote almost the same logic inside our Get
type.
Conclusion
That’s it for today. This article already got pretty long. In part 2, I will write about the set
function.
The source code for this tutorial could be found here (leave a star if you liked it) or in the TypeScript playground. I also publish it as a npm package.
I hope that you’ve got some value from this article. If you liked it make sure to share it with your friends.
Take care.