Making lodash function get type safe
Tags: typescript, generics, safety, lodash
Published: 2021-04-05 15:34:37

With the introduction of Typescript 4.1 we can use template literal types for writing our generics. This opens up a whole lot of new applications and better typing, also means they are more complex to read.

In this post I will be going over why and how I implemented the following result:
```typescript
expectType<RegExp>(get(obj,'deeplvl1[1].deeplvl2.deeplvl3[88].deeplvl4.value'));
```
## Table of Content
1. [Why I needed](#why)
2. [The End Result](#result)
3. [How It Works](#how)
4. [Example](#example)
5. [Future Work](#future)

## Why <a name="why"></a>
### Context
In my current project we are using `react-final-form` to create our forms. In one of our pages we have a huge object (or as we can call it a god object) that is allowed to be edited in several visible sections. This is bad design by nature, but unfortunately this technical debt is too large to currently solve. We wrote several hooks to make our live easier. One of them is:

```typescript
export function useFormValue<T>(field: string): T {
  const fieldInput = useField(field, {
    subscription: {
      value: true
    }
  });
  return fieldInput.input.value;
}
```

Which can be used as

```typescript
export default function DeepNestComponent() {
  const price = useFormValue<number>('customers[3].contract.price');
  return (<div>{price}</div>);
}
```

This allows us to create deep nested components that can subscribe to form changes of a specific form input and display the value.

### The problem 
What if the structure of the large object that is being edited changes? We then have to go over all the components and adjust the path. If you have full unit test coverage, a (snapshot) test will probably fail. But I rather have my compiler give me an error than hoping that my unit coverage is sufficient.

## The End Result <a name="result"></a>
I ended up writing the following generic
```typescript
export type ResolveType<ObjectType, Path extends string, OrElse> =
    Path extends keyof ObjectType ? ObjectType[Path] :
    Path extends `${infer LeftSide}.${infer RightSide}` ? LeftSide extends keyof ObjectType ? ResolveType<ObjectType[LeftSide], RightSide, OrElse> : 
    Path extends `${infer LeftSide}[${number}].${infer RightSide}` ? LeftSide extends keyof ObjectType ? ObjectType[LeftSide] extends Array<infer U>? ResolveType<U,RightSide, OrElse> : OrElse : OrElse : OrElse :
    Path extends `${infer LeftSide}[${number}]` ? LeftSide extends keyof ObjectType ? ObjectType[LeftSide] extends Array<infer U> ? U : OrElse : OrElse : OrElse;
```

Which is uploaded to {% github pouja/typescript-deep-path-safe no-readme %}

And it can be used as:

```typescript

function get<ObjecType extends object, Path extends string, OrElse extends unknown>(obj:ObjecType, path: Path, orElse?: OrElse): ResolveType<ObjecType, Path, OrElse> {
  return _get(obj, path, orElse);
}

const nested = get(obj, 'my.deep[3].nested', 'orElse');

```

## How it works <a name="how"></a>
My starting point was the implementation of template literal types:
{% github https://github.com/microsoft/TypeScript/pull/40336 no-readme %}

Where already a start was made which works with only objects without arrays.

But I want to go over step by step in how it works and how I extend it.

### Single Nested Object

To make it easier to reason with generics, I will view them as functions. Example `type ResolveType<ObjectType extends object, Path extends string, OrElse>` is function called `ResolveType` which accepts 3 arguments. First argument should be of (sub)type `object`, second argument must be a `string` and the third argument is of type `any`.

Our function must return type of the single level nested path or if it does not exists then return the third argument. In pseudo code it will look like:
```
function ResolveType(obj:Object, path:String, orElse:any) {
 if(path exists in obj) return type of obj[path]
 else return orElse;
}
```

To retrieve all the keys of a object we can use [`keyof`](https://www.typescriptlang.org/docs/handbook/2/keyof-types.html#the-keyof-type-operator) operator. To check if type `a` is of type `b` we can use the `extends` operator. This covers our `if condition`. The `if-else` equivalent in generics are ternary statements. We then get the following result:

```typescript
export type ResolveType1<ObjectType, Path extends string, OrElse> =
    Path extends keyof ObjectType ? ObjectType[Path] : OrElse;
```

### Unlimited Nested Objects
Now we must use the new [template literal types](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html) of Typescript, introduced in 4.1. This allows us, as I call it, to do pattern matching. Furthermore because nested objects can be nested more than 10 levels deep, we have to either loop or recursively call our generic type resolver. In 4.1 they also introduced [Recursive Conditional Types](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-1.html#recursive-conditional-types) which allows us to recursively walk through all the nested levels of our object.

Our pseudo code will look like:
```text
function ResolveType(obj:Object, path:String, orElse:any) {
 if(path exists in obj) return type of obj[path]
 else if(path can be splitted between '.') {
   if(leftside of '.' is a key of obj) {
     return ResolveType(obj[leftSide], rightSide, orElse);
   }
 }
 return orElse;
} 
```

First condition can be written as ```Path extends `${string}.${string}` ``` But we don't want to just check if the path matches the pattern, but we also want to use the matched string. This can be achieved with the `infer` keyword, which is not documented in detail in the handbook of Typescript. Our condition becomes ```Path extends `${infer LeftSide}.${infer RightSide}` ``` The last condition we have to check is the same we already covered for single level nested objects.

Typescript doesn't support a `AND` operator in generics, so we have to used nested ternary.

Our function becomes:

```typescript
export type ResolveType<ObjectType, Path extends string, OrElse> =
    Path extends keyof ObjectType ? ObjectType[Path] :
    Path extends `${infer LeftSide}.${infer RightSide}` ? LeftSide extends keyof ObjectType ? ResolveType<ObjectType[LeftSide], RightSide, OrElse> : OrElse : OrElse;
```

### Arrays
The pattern of arrays are as followed: `nested[3]` which is the simple variant. Our pseudo code becomes:
```text
function ResolveType(obj:Object, path:String, orElse:any) {
 if(path exists in obj) return type of obj[path]
 else if(path can be splitted between '.') {
   if(leftside of '.' is a key of obj) {
     return ResolveType(obj[leftSide], rightSide, orElse);
   }
 } else if(path matches with pattern 'string.[number]') {
   if(leftside of [number] is a key of obj) {
     if(obj[leftside] is type of array) {
       return type of array
     }
   }
 }
 return orElse;
} 
```

Array notation can be pattern matched with `${infer LeftSide}[${number}]`. We are just matching if a number is being used, since we can not infer the size of an array are compile time. If you use an index outside of the range you will get a Index Out of Bounds Exception, which is the only downside of the constructed generic. 

We can also use the `infer` key word to infer the type of array we are using: `Array<infer U>`. Typescript will then infer the type of the array. 

We then get the following function:

```typescript
export type ResolveType<ObjectType, Path extends string, OrElse> =
    Path extends keyof ObjectType ? ObjectType[Path] :
    Path extends `${infer LeftSide}.${infer RightSide}` ? LeftSide extends keyof ObjectType ? ResolveType<ObjectType[LeftSide], RightSide, OrElse> : OrElse :
    Path extends `${infer LeftSide}[${number}]` ? LeftSide extends keyof ObjectType ? ObjectType[LeftSide] extends Array<infer U> ? U : OrElse : OrElse : OrElse;
```

### Nested arrays
We can now match the following constructions:
```typescript
expectType<number>(get(obj, 'nested.a'));
expectType<string>(get(obj, 'normal'));
expectType<number>(get(obj,'nested.a'));
expectType<boolean>(get(obj,'nested.b.c'));
expectType<number[]>(get(obj,'arr'));
expectType<number>(get(obj,'arr[13]'));
expectType<string[]>(get(obj,'anothernested.deep'));
```

I am using the following library for testing my types: {% github SamVerschueren/tsd no-readme %}

But we can not match for example `arr[13].length` because we only check for the pattern `string[number]`. We need to match for `string[number].string` and infer the types. But `arr[13].length` will match with our second condition in our pseudo code:

```text
if(path can be splitted between '.')
```

So in our else statement of the second condition we have to check if it matches the pattern `string[number].string`. And then recursively call our function with the right side of the dot. Our pseudo code becomes:

```text
function ResolveType(obj:Object, path:String, orElse:any) {
 if(path exists in obj) return type of obj[path]
 else if(path can be splitted between '.') {
   if(leftside of '.' is a key of obj) {
     return ResolveType(obj[leftSide], rightSide, orElse);
   } else if(path contains pattern 'string[number].string') {
     if(leftSide of [number] is a key of obj) {
       if(obj[leftSide] is an array) {
         return ResolveType(type of array, rightSide, orElse)
       }
     }
   }
 } else if(path matches with pattern 'string.[number]') {
   if(leftSide of [number] is a key of obj) {
     if(obj[leftSide] is type of array) {
       return type of array
     }
   }
 }
 return orElse;
} 
```

As you can see the added `if-else` condition is similar to last one, except we recursively walk through the right side of the dot.

Combining everything until now we get the final result:
```typescript
export type ResolveType<ObjectType, Path extends string, OrElse> =
    Path extends keyof ObjectType ? ObjectType[Path] :
    Path extends `${infer LeftSide}.${infer RightSide}` ? LeftSide extends keyof ObjectType ? ResolveType<ObjectType[LeftSide], RightSide, OrElse> : 
    Path extends `${infer LeftSide}[${number}].${infer RightSide}` ? LeftSide extends keyof ObjectType ? ObjectType[LeftSide] extends Array<infer U>? ResolveType<U,RightSide, OrElse> : OrElse : OrElse : OrElse :
    Path extends `${infer LeftSide}[${number}]` ? LeftSide extends keyof ObjectType ? ObjectType[LeftSide] extends Array<infer U> ? U : OrElse : OrElse : OrElse;
```

## Example <a name='example'></a>
Lets take the lodash get example and step by step walk through how the generic will be called and how it will resolve:

```
const barLength = get(obj,'foo.bar[3].length', -1);
```

We will assume that `obj` is of type `FooBar` and the path is valid. The generic will then be called with `ResolveType<FooBar, 'foo.bar[3].length', number)`

1. `foo.bar[333].length` matches the pattern `string.string`, Typescript will infer the following values `leftSide=foo` and `rightSide=bar[333].length`
2. `foo` is a key of `FooBar`, lets assume that `FooBar['foo']` is of type `Foo`.
3. `ResolveType` will be called with `ResolveType<Foo, 'bar[3].length', number>`
4. `bar[3].length` matches the pattern `string.string`, Typescript will infer the following values `leftSide=bar[3]` and `rightSide=length`
5. `bar[3]` is not a key of `Foo`
6. `bar[3].length` matches the pattern `string[number].string`, Typescript will infer the following values `leftSide=bar` and `rightSide=length`
7. `bar` is a key of `Foo`
8. `Foo['bar']` is an array of type `Bar`
9. `ResolveType` will be called with `ResolveType<Bar, 'length', number>`
10. `length` is a key of `bar` since `bar` is a `string`
11. `ResolveType` returns the type `number`

## Future Work <a name="future"></a>
### Publishing
Currently you can copy the type from this article or from my repository:
{% github pouja/typescript-deep-path-safe %}

I hope in the near future to publish it to NPM so you can install it (when I figure out how).

### Autocomplete
I want to look into making generic type where is accepts variable list of function arguments to determine the type. This allows you to use auto completion of typescript itself.
The end result should look like:

```
get(obj, 'orElse', 'lvl1path', 3, 'lvl2path', '[your cursor]
```

Where in the last argument you will get the autocomplete of Typescript for the type `obj.lvl1path[3].lvl2path`.