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

April 24, 2021

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:

  1. Value - the object to get value from.
  2. Path - the path at which the value is located.
  3. 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:

  1. We check that Value isn’t primitive by comparing it to AnyObject, otherwise we return Default. Also since we have conditional inside of our type, it forces TS to distribute the Value (read more about type distribution at the end of the article), which means that our type will work correctly with unions.
  2. If a value is an object, we call the GetKey type.
  3. Inside the GetKey we check that Path[Index] is present in the Value. If so, we recursively call Get with a new value (Value[Path[Index]]) and incremented index (Sequence[Index]).
  4. Path[Index] isn’t a key of Value. Return Default.
  5. Once we iterated through the pass, we return Value or if Value is undefined - Default. Note that the condition Value extends undefined will also force our Value 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
  1. Instead of immediately returning Default we check that value is array since we know that they may not have literal indexes.
  2. 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.
  3. Value is an array and Path[Index] is numeric, but there is still one thing that we have to assert. That the Value isn’t tuple. If it is, return Default.
  4. All of the above conditions are met, we get the value of an array through GetArrayValue type and add undefined to it because we can’t know for sure that this index exists on an array (similar to the noUncheckIndexAccess rule in tsconfig). And this value is passed to Get_ type with all other arguments and incremented Index.
  5. 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.


© 2023 Ayub Begimkulov All Rights Reserved