TypeScript: type-safe get and set functions - part 2

May 10, 2021

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:

  1. We iterate over the Obj keys and Path[Index](in case it’s not the key of Obj).
  2. We check that the current key (K) is Path[Index]
  3. If so, we call Set_ with Obj[K] and incremented index Sequence[Index] (I explained this technique in the first part)
  4. Otherwise, we just set the value for K to Obj[K]

Ok, now we can test the type that we wrote above:

Try in playground

// 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

  1. First of all, instead of directly passing Obj[K] to the Set_, now we call GetNextObject type with next value - Obj[K] and next path item - Path[Sequence[Index]].
  2. In the GetNextObject type, before anything, we check that Value is never. This check is needed because never is assignable to everything, so our extends AnyObject check will pass with never Value, which isn’t correct. Therefore if check passes, we return DefaultObject<NextKey>
  3. If Value isn’t never we can safely check that it is an object, if so we return Value, otherwise DefaultObject<NextKey>.
  4. Inside DefaultObject type we will return an object or array based on the NextKey. If it’s numeric (e.g. '5') then [] else - {}. Note that we’ve written an IsNumericKey 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:

Try in playground

// 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!


© 2023 Ayub Begimkulov All Rights Reserved