Hey, today we gonna continue with our get
and set
functions. If you haven’t checked the first part make sure to read it first, since we gonna use some of the previous code in this tutorial.
Let’s get started!
set
If you don’t remember, we’ve finished with pathToString
and get
functions, so in this article, we gonna create a set
function.
And as we did in the part 1 we will start by creating a Set
type that will be used to correctly type our set
implementation.
// from part 1
interface AnyObject {
[key: string]: any;
}
type Set<
Obj extends AnyObject,
StringPath extends string,
Value
> = StringPath extends unknown
? Set_<Obj, StringToPath<StringPath>, Value>
: never;
type Set_<
Obj extends AnyObject,
Path extends string[],
Value,
Index extends number = 0
> = any;
Like we did with our Get
type, we created 2 types Set
and Set_
. The first one will make sure that our StringPath
is distributed correctly, in case we’ll have a union path, and create an array path from StringPath
by leveraging the StringToPath
type from part 1. Also, it makes sure that the user doesn’t accidentally pass Index
which will be used for storing the current path index and must always start from 0
.
The second type (Set_
) will accept “prepared” arguments and that’s where all the type related logic will be:
type Set_<
Obj extends AnyObject,
Path extends string[],
Value,
Index extends number = 0
> = {
0: any; // 2 TODO implement
1: Path['length'] extends 0 ? Obj : Value; // 3
}[Index extends Path['length'] ? 1 : 0]; //1
First of all, we gonna check that Index
doesn’t exceed Path['length']
(// 1
). If it does, we’ll go to the case 1
(// 3
).
There we will return Obj
if the Path
is empty or Value
if it’s not.
However, if Index
doesn’t exceed Path['length']
we will apply our type manipulations (// 2
).
So now we could start writing the main logic of the Set_
type:
type Sequence = [1, 2, 3, /* ... */ 16]; // from part 1
type Set_<
Obj extends AnyObject,
Path extends string[],
Value,
Index extends number = 0
> = {
0: {
[/* 1 */ K in keyof Obj | Path[Index]]: K extends Path[Index] // 2
? Set_<Obj[K], Path, Value, Sequence[Index]> // 3
: Obj[K]; // 4
};
1: Path['length'] extends 0 ? Obj : Value;
}[Index extends Path['length'] ? 1 : 0];
Let’s go through the code above and understand what we do:
- We iterate over the
Obj
keys andPath[Index]
(in case it’s not the key ofObj
). - We check that the current key (
K
) isPath[Index]
- If so, we call
Set_
withObj[K]
and incremented indexSequence[Index]
(I explained this technique in the first part) - Otherwise, we just set the value for
K
toObj[K]
Ok, now we can test the type that we wrote above:
// works
type Basic = Set<{ a: number }, 'a', string>;
type Nested = Set<{ a: { b: number } }, 'a.b', string>;
type NewKey = Set<{ a: number }, 'b', string>;
// doesn't work as expected
type WorksWithArray = Set<{ a: [1, 2, 3] }, 'a.2', string>;
type NewKeyArray = Set<{}, 'a.0', string>;
type Overwrite = Set<{ a: number }, 'a.b', string>;
type OverwriteArrayKey = Set<{ a: number }, 'a.0', string>;
type NonTupleArrays = Set<{ a: number[] }, 'a.5', string>;
type ArrayWithNotNumberKey = Set<[1, 2, 3], 'a', string>;
type ArrayFill = Set<[], '5', string>;
You could see that the first 3 tests work correctly, however, the rest of them give incorrect results.
For instance, arrays get transformed into objects with array properties and methods. A similar thing happens to the Overwrite
case, instead of overwriting a number with an object, we get an object with b
and all number methods (toFixed
, etc.).
Also if you look at the NewKeyArray
case, a
should be [string]
, but it’s { 0: string }
.
To summer it up, we have the next list of issues:
- Incorrect overriding of primitive values
- If a key isn’t present and the next key is numeric, we should set the default value to array, not object
- Arrays should not be converted into objects with the same properties/methods
- Tuples should update/add value at a particular index, arrays should add another element type
Let’s start fixing these issues step by step.
Incorrect overriding of primitive values and incorrect object selection for not present keys (problems 1 & 2)
This issue is happening due to the reason that we aren’t checking that the value passed to Set_
recursively is an object. Although Set_
has a extends AnyObject
constraint, TS doesn’t show an error because it can not know for sure what Obj[K]
is.
To fix this we must manually check that the next value (Obj[K]
) is an object, otherwise, override the primitive/undefined value with {}
or []
depending on the next path item.
type Set_<
Obj extends AnyObject,
Path extends string[],
Value,
Index extends number = 0
> = {
0: {
[K in keyof Obj | Path[Index]]: K extends Path[Index]
? Set_<
// 1
GetNextObject<Obj[K], Path[Sequence[Index]]>,
Path,
Value,
Sequence[Index]
>
: Obj[K];
};
1: Path['length'] extends 0 ? Obj : Value;
}[Index extends Path['length'] ? 1 : 0];
type GetNextObject<Value, NextKey extends string> =
// 2
[Value] extends [never]
? DefaultObject<NextKey>
: Value extends AnyObject // 3
? Value
: DefaultObject<NextKey>;
// 4
type DefaultObject<Key extends string> = IsNumericKey<Key> extends true
? []
: {};
Let’s go through the key points in the code above
- First of all, instead of directly passing
Obj[K]
to theSet_
, now we callGetNextObject
type with next value -Obj[K]
and next path item -Path[Sequence[Index]]
. - In the
GetNextObject
type, before anything, we check thatValue
isnever
. This check is needed becausenever
is assignable to everything, so ourextends AnyObject
check will pass with neverValue
, which isn’t correct. Therefore if check passes, we returnDefaultObject<NextKey>
- If
Value
isn’tnever
we can safely check that it is an object, if so we returnValue
, otherwiseDefaultObject<NextKey>
. - Inside
DefaultObject
type we will return an object or array based on theNextKey
. If it’s numeric (e.g.'5'
) then[]
else -{}
. Note that we’ve written anIsNumericKey
type in the part 1.
Arrays should not be converted into objects and correct tuple updates
Since the arrays have a different structure, we can’t iterate over them in the same way as we do with objects. So let’s add a separate case for arrays:
// extends AnyObject so we could use any string
// to access array properties
interface AnyArray extends ReadonlyArray<unknown>, AnyObject {}
type Set_<
Obj extends AnyObject,
Path extends string[],
Value,
Index extends number = 0
> = {
0: Obj extends AnyArray // 1
? SetArray<Obj, Path, Value, Index>
: {
[K in keyof Obj | Path[Index]]: K extends Path[Index]
? Set_<
GetNextObject<Obj[K], Sequence[Index]>,
Path,
Value,
Sequence[Index]
>
: Obj[K];
};
1: Path['length'] extends 0 ? Obj : Value;
}[Index extends Path['length'] ? 1 : 0];
type SetArray<
Arr extends AnyArray,
Path extends string[],
Value,
Index extends number
> = IsNumericKey<Path[Index]> extends false
? Arr & Set_<{}, Path, Value, Index> // 2
: // 3
(
| GetArrayValue<Arr> // current array value
// updated array value
| Set_<
GetNextObject<GetArrayValue<Arr>, Path[Sequence[Index]]>,
Path,
Value,
Sequence[Index]
>
)[];
We check in the Set_
that Obj
extends AnyArray
, if so we call SetArray
(// 1
). Inside SetArray
we have 2 cases. First for non-numeric keys, second for numeric.
So if a key isn’t numeric(// 2
), we set the Value
to an empty object and intersect it with our array. It will prevent arrays from being converted to array-like objects.
Otherwise, if a key is numeric, we create a union of current array value (GetArrayValue<Arr>
) and updated array value (we call Set_
on array value, similar to what we do with objects).
Ok, great. We’ve tackled the problem with arrays being converted to objects. However, if we pass tuple right now to SetArray
, it will be converted to regular array. Therefore we must add one more case for tuples inside SetArray
:
type SetArray<
Arr extends AnyArray,
Path extends string[],
Value,
Index extends number
> = IsNumericKey<Path[Index]> extends false
? Arr & Set_<{}, Path, Value, Index>
: IsTuple<Arr> extends false // 1
? (
| GetArrayValue<Arr>
| Set_<
GetNextObject<GetArrayValue<Arr>, Path[Sequence[Index]]>,
Path,
Value,
Sequence[Index]
>
)[]
: SetTuple<
Arr,
Path[Index],
// 2
// updated value of the array element
Set_<
GetNextObject<Arr[Path[Index]], Path[Sequence[Index]]>,
Path,
Value,
Sequence[Index]
>
>;
In the code above we check if Arr
is a tuple or not (we wrote IsTuple
type in part 1). If it’s, we’ll call a SetTuple
type with the index at which the value should be updated and the value for this index.
To simplify it a bit we could write this code in few steps:
type CurrentPathItem = Path[Index];
type CurrentArrayValueForIndex = Arr[CurrentPathItem];
type NextObject = GetNextObject<
CurrentArrayValueForIndex,
Path[Sequence[Index]]
>;
type UpdatedValueForCurrentIndex = Set_<
NextObject,
Path,
Value,
Sequence[Index]
>;
type UpdatedTuple = SetTuple<Arr, CurrentPathItem, UpdatedValueForCurrentIndex>;
Unfortunately, we can’t make such separation in our TS type, that’s why it gets unreadable sometimes.
Now we need to create a SetTuple
type that will update a tuple at particular index:
type SetTuple<
Arr extends AnyArray,
Index extends string,
Value
> = SetTuple_<
Arr,
Index,
Value
>;
type SetTuple_<
Arr extends AnyArray,
Index extends string,
Value,
Result extends AnyArray = [],
CurrentIndex extends number = Result['length']
> = {
0: SetTuple_<Arr, Index, Value, [...Result, Arr[CurrentIndex]]>; // 2
1: [...Result, Value, ...GetTupleRest<Arr, Sequence[CurrentIndex]>]; // 3
}[`${CurrentIndex}` extends Index ? 1 : 0]; // base check
First of all, we created a subtype called SetTuple_
so that we couldn’t pass more than 3 arguments to SetTuple
.
Inside of the SetTuple_
type we have 2 cases and a base check (similar to other types we’ve written so far).
The base check ensures that we don’t move further after CurrentIndex
(length of the Result
) reached the Index
at which we want to set the Value
.
If we haven’t reached the index, we copy the element at CurrentIndex
from Arr
to Result
and recursively call SetTuple_
with a new result(// 2
).
When we finally reach the Index
(// 3
), we return an array of accumulated Result
, Value
, and the elements of Arr
array after Index
(we will write GetTupleRest
later in the article).
To make it easier to understand, lets create a JS equivalent of SetTuple
type:
function setTuple(arr, index, value) {
return setTuple_(arr, index, value);
}
function setTuple_(
arr,
index,
value,
result = [],
currentIndex = result.length
) {
// `index` will be passed as string
if (`${currentIndex}` === index) {
return [...result, value, ...getTupleRest(arr, currentIndex + 1)];
}
return setTuple_(arr, index, value, [...result, arr[currentIndex]]);
}
function getTupleRest(arr, index) {
/* ... */
}
Ok, now let’s write a GetTupleRest
type that will return all elements of tuple starting from Index
:
type GetTupleRest<
Tuple extends AnyArray,
Index extends number,
Keys extends string = GetTupleKeys<Tuple>
> = `${Index}` extends Keys ? GetTupleRest_<Tuple, Index, Keys> : []; // 1
type GetTupleRest_<
Tuple extends AnyArray,
Index extends number,
Keys extends string,
Result extends AnyArray = []
> = Tuple['length'] extends Index // 2
? Result
: GetTupleRest_<Tuple, Sequence[Index], Keys, [...Result, Tuple[Index]]>; // 3
// 4
type GetTupleKeys<Tuple extends AnyArray> = Extract<keyof Tuple, `${number}`>;
GetTupleRest
will accept 2 arguments (Tuple
and Index
), however, Keys
will be always set by default to all numeric keys of the Tuple
.
Inside of the type, we will check that Index
is one of the numeric keys of Tuple
. If the check fails it means that Index
is bigger than the last index of Tuple
, therefore we return an empty array ([]
). Otherwise, we call a GetTupleRest_
type and pass Tuple
, Index
, and Keys
to it.
GetTupleRest_
will be the type that’ll recursively go from initially passed Index
up to the end of the array and copy all elements to the Result
array.
So inside of it we, firstly, check that Tuple['length']
hasn’t reached the Index
. If so, we recursively call GetTupleRest_
with incremented index (Sequence[Index]
) and a new result - [...Result, Tuple[Index]]
(// 3
). Otherwise, we simply return the Result
.
And continuing our comparison of TS types with JS functions, we can write GetTupleRest
in JS like that:
function getTupleRest(tuple, index, keys = Object.keys(tuple)) {
if (keys.includes(`${index}`)) return getTupleRest_(tuple, index, keys);
return [];
}
function getTupleRest_(tuple, index, keys, result = []) {
if (tuple.length === index) return result;
return getTupleRest_(tuple, index + 1, keys, [...result, tuple[index]]);
}
Testing
Ok, after we’ve finished fixing our issues, let’s test our update Set
type:
// works
type Basic = Set<{ a: number }, 'a', string>;
type Nested = Set<{ a: { b: number } }, 'a.b', string>;
type NewKey = Set<{ a: number }, 'b', string>;
type WorksWithArray = Set<{ a: [1, 2, 3] }, 'a.2', string>;
type NewKeyArray = Set<{}, 'a.0', string>;
type Overwrite = Set<{ a: number }, 'a.b', string>;
type OverwriteArrayKey = Set<{ a: number }, 'a.0', string>;
type NonTupleArrays = Set<{ a: number[] }, 'a.5', string>;
type ArrayWithNotNumberKey = Set<[1, 2, 3], 'a', string>;
type ArrayFill = Set<[], '5', string>;
As you can see, everything works as expected.
Function implementation
After we’ve finished with our Set
type, we need to create a function that will leverage it to provide a type-safe object update:
// utils from part 1
const hasOwnProperty = Object.prototype.hasOwnProperty;
export const hasOwn = <T extends AnyObject>(
obj: T,
key: PropertyKey
): key is keyof T => hasOwnProperty.call(obj, key);
export const isObject = (value: unknown): value is AnyObject =>
typeof value === 'object' && value !== null;
export const isUndefined = (value: unknown): value is undefined =>
typeof value === 'undefined';
interface SetFunction {
<Obj extends AnyObject, Path extends string, Value>(
object: Obj,
path: Path,
value: Value
): Set<Obj, Path, Value>;
}
export const set: SetFunction = (object, stringPath, value) => {
let index = -1;
// `stringToPath` function from part 1
const path = stringToPath(stringPath);
const lastIndex = path.length - 1;
// `currentObject` will change during iteration
// cast to `AnyObject`
let currentObject: AnyObject = object;
while (++index <= lastIndex) {
const key = path[index]!;
// changes during iterations
// casting to unknown
let newValue: unknown = value;
if (index !== lastIndex) {
const objValue = object[key]!;
newValue = isObject(objValue)
? objValue
: !isNaN(+path[index + 1]!)
? []
: {};
}
currentObject[key] = newValue;
currentObject = currentObject[key];
}
// because it's not known what `SetFunction`
// since it's a generic type, we cast `object`
// to any so it's compatible with the return type
return object as any;
};
Conclusion
If you’ve made it so far, I guess you are a big TS enthusiast. So I hope that you’ve learned something from it.
You could find a complete source code in github repo or in the playground. I also published it to the npm.
Share this article with your friends if you liked it. It helps.
Take care!