TypeScript Utility: keyof nested object

pffigueiredo

Pedro Figueiredo

Posted on December 15, 2021

TypeScript Utility: keyof nested object

In this blog post, we will learn how to build a TypeScript util type, that exposes all the key paths of an object, including the nested ones.

Why is that useful?

Have you ever built TypeScript function that receives a specific property of an object, by specifying the object and the path to that object's property? Something like this:



const person = {
  name: "John",
  age: 30,
  dog:{
    name: "Rex",
  }
}

function get<ObjectType>(object: ObjectType, path: string){
  const keys = path.split('.');
  let result = object;
  for (const key of keys) {
    result = result[key];
  }
  return result;
}

get(person, "dog.name") // Rex


Enter fullscreen mode Exit fullscreen mode

Well, obviously this works very well, but you aren't taking full advantage of TypeScript! You can easily do a typo on the second argument (path) and lose some precious type with debugging this.

How can TypeScript help us then?

Unfortunately for us, there isn't yet a native utility type that can provide us all the key paths inside a nested object. But if your object only has 1 level of deepness, TypeScript's keyof operator will serve just fine!



const person = {
  name: "John",
  age: 30,
  job: "Programmer"
}

function get<ObjectType>(object: ObjectType, 
  path: keyof ObjectType & string){
 ...
}


Enter fullscreen mode Exit fullscreen mode

This way, you will have a real type safe function, that will only allow you to add "name", "age" or "job" as the second argument.

keyof example gif

If you didn't understand some of technicalities I showed above, stay with me, as I will explain in more detail bellow.

Objects with more than 1 level deepness

Now, for the objects with more than 1 level of deepness, keyof isn't nearly enough as you may have realized by now.

Before entering in TypeScript's implementation details, let's try to think of an algorithm that would allow us to get all the keys of an object with N levels of deepness.

  1. Go through the object's keys
  2. If the key's value is not an object , then it's a valid key
  3. Else, if the key is an object, concat this key and go back to step 1

With this algorithm, and these "simple" programming principles, a loop statement, a conditional and recursiveness, this doesn't seem so hard after all!

Now, let's take that algorithm and build a JS function that could extract all the keys of all the nodes in any given object.



const objectKeys = [];
const person = {
    name: 'pfigueiredo',
    age: 30,
    dog: {
        owner: {
            name: 'pfigueiredo'
        }
    }
};

function getObjectKeys(obj, previousPath = '') {
    // Step 1- Go through all the keys of the object
    Object.keys(obj).forEach((key) => {
        // Get the current path and concat the previous path if necessary
        const currentPath = previousPath ? `${previousPath}.${key}` : key;
        // Step 2- If the value is a string, then add it to the keys array
        if (typeof obj[key] !== 'object') {
            objectKeys.push(currentPath);
        } else {
            objectKeys.push(currentPath);
            // Step 3- If the value is an object, then recursively call the function
            getObjectKeys(obj[key], currentPath);
        }
    });
}

getObjectKeys(person); // [ 'name', 'age', 'dog', 'dog.owner', 'dog.owner.name' ]


Enter fullscreen mode Exit fullscreen mode

So, we know how to do this programmatically, the goal now, is to try and apply the same kind of concepts with TypeScript existing operators and utility types to build a generic type that will give us all the keys of an object as literal types.

Creating the TypeScript utility type

The utility type we will create bellow, is only possible since TypeScript 4.0 version was released, as it introduced literal types.

In this section, we will go step by step, on how to create a TypeScript's utility type that is capable of extract all keys inside any given object.

Type definition

The first step to create this utility, is obviously declaring a new TypeScript type and give it a name:

1- Declaring a new type



type NestedKeyOf = {};


Enter fullscreen mode Exit fullscreen mode

The next step, is to make this type be "generic", meaning, it should accept any given object that we pass into it.
TypeScript already has this generic feature embedded, and it allows us to create a flexible util that can accept any given object.

2- Accept a generic type parameter



type NestedKeyOf<ObjectType> = {};

// using
type ObjectKeys = NestedKeyOf<Person>;


Enter fullscreen mode Exit fullscreen mode

Adding a generic type parameter by itself doesn't restraint the type you can pass into the utility. For that, we need to add the extends keyword, in order to only accept object types - any type that follows the "key-value" pair data type.

3- Constraint the generic parameter



type NestedKeyOf<ObjectType extends object> = {};


Enter fullscreen mode Exit fullscreen mode

Great, we have a defined the type's signature, now we need to do the "real work", that is, making the implementation.

Type implementation

Going back to our algorithm, the first step to create this utility is "Go through the object's keys". TypeScript makes this easy for us with something called Mapped Types, which is a way to go through an object's keys and set the value's type based on each one of the keys.

1- Going through the object's keys



// Create an object type from `ObjectType`, where the keys
// represent the keys of the `ObjectType` and the values 
// represent the values of the `ObjectType`
type NestedKeyOf<ObjectType extends object> = 
{[Key in keyof ObjectType]: ObjectType[Key]};


Enter fullscreen mode Exit fullscreen mode

Now that we were able to go through all the object's keys and use them to access each one of the object's values, we can move on to the 2nd step of the algorithm: "If the key's value is not an object , then it's a valid key".

We are going to do that check by making usage of TypeScript's Conditional Types, which work as following:



// Take a `Type`, check if it "extends" `AnotherType` 
// and return a type based on that
type Example = Dog extends Animal ? number : string;


Enter fullscreen mode Exit fullscreen mode

2- Checking if it's a valid key



// If the value is NOT of type `object` then 
// set it as the generated object's value type
type NestedKeyOf<ObjectType extends object> = 
{[Key in keyof ObjectType]: ObjectType[Key] extends object 
? "" /*TODO*/ 
: Key
};

// But we want what's under the object's values, 
// so we need to access it
type NestedKeyOf<ObjectType extends object> = 
{...}[keyof ObjectType];


type Person = {
    name: 'pfigueiredo',
    age: 30,
    dog: {
        owner: {
            name: 'pfigueiredo'
        }
    }
};
NestedKeyOf<Person>; // "name" | "age" | ""


Enter fullscreen mode Exit fullscreen mode

So, we now have access to all the object's first level keys, but we are obviously still missing the path to the other level's properties, such as dog.owner and dog.owner.name.

In order to achieve that, we should follow the 3rd step of our algorithm: "Else, if the key is an object, concat this key and go back to step 1."

To achieve that, we need to make usage of TypeScript's recursive types, which work as any other programming language really - having a condition that calls the same "type" that invoked the condition (recursiveness), and having a condition that leads to an actual result.

3 - Add type recursiveness



// 1 - If it's an object, call the type again
type NestedKeyOf<ObjectType extends object> = 
{[Key in keyof ObjectType]: ObjectType[Key] extends object 
? NestedKeyOf<ObjectType[Key]>
: Key
}[keyof ObjectType];

// 2 - Concat the previous key to the path
type NestedKeyOf<ObjectType extends object> = 
{[Key in keyof ObjectType]: ObjectType[Key] extends object 
? `${Key}.${NestedKeyOf<ObjectType[Key]>}`
: Key
}[keyof ObjectType];

// 3 - Add the object's key
type NestedKeyOf<ObjectType extends object> = 
{[Key in keyof ObjectType]: ObjectType[Key] extends object 
? `${Key}` | `${Key}.${NestedKeyOf<ObjectType[Key]>}`
: Key
}[keyof ObjectType];


Enter fullscreen mode Exit fullscreen mode

That is basically it, this NestedKeyOf utility type should already be capable of extracting all the possible property paths of an object with any given depth, but TypeScript will probably still be yelling at you for using non-strings/numbers inside the literals, let's fix that!

In order to only select keys of a specific type, we need to leverage the Intersection Types, which is just a matter of using the & operator.

4- Extracting string/number keys only



// add `& (string | number)` to the keyof ObjectType
type NestedKeyOf<ObjectType extends object> = 
{[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object 
? `${Key}` | `${Key}.${NestedKeyOf<ObjectType[Key]>}`
: `${Key}`
}[keyof ObjectType & (string | number)];


Enter fullscreen mode Exit fullscreen mode

SortBy sample with NestedKeyOf

Now that we have finalised the implementation of our TypeScript utility type, it's time to see a simple sample where it would be super useful in any project you might be working in πŸ‘‡

sortBy function gif

By using this utility in the sortBy function, we are able to safely select one of the object's properties and make sure we don't do any typo and keep in sync with the object's structure and what we are passing at all times 🀯

Summary

  1. Create a type that accepts a generic
  2. Constraint the generic to be an object
  3. Create a new object with the help of Mapped Types
  4. For each key, check if the value is an object or a primitive type
  5. If it's an object then concat the current key and call the type in a recursiveness manner
  6. Only look for string and number keys

As a side note, I wanna appreciate the fantastic David Sherret, which posted a stack overflow answer that looked somewhat like the utility type I described above πŸ™

πŸ’– πŸ’ͺ πŸ™… 🚩
pffigueiredo
Pedro Figueiredo

Posted on December 15, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related