DEV Community

Cover image for TypeScript Utility: keyof nested object
Pedro Figueiredo
Pedro Figueiredo

Posted on • Updated on

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 🙏

Top comments (15)

Collapse
 
krixpello profile image
KriXPello • Edited

To hide Array methods use this:

type NestedKey<O extends Record<string, unknown>> = {
[K in Extract<keyof O, string>]: O[K] extends Array<any>
? K
: O[K] extends Record<string, unknown>
? `${K}` | `${K}.${NestedKey<O[K]>}`
: K
}[Extract<keyof O, string>];

Collapse
 
pffigueiredo profile image
Pedro Figueiredo

Yap, there are multiple ways to hide an array, if I was aiming into that, something similar to this would probably be my bet, thanks ❤️

Collapse
 
abetoots profile image
abe caymo

hi! can't thank you enough for this awesome post. still new to TS but how do I use this util for a function that returns an object which contains all keys generated from <NestedKeyOf> with values as string ?

Collapse
 
pffigueiredo profile image
Pedro Figueiredo

Hey Abe, thanks a lot for the feedback ;)

Could you try to provide me an example of what you are trying to achieve? Maybe using ts playground - you just need to edit and share the link after ;)

Collapse
 
abetoots profile image
abe caymo

so it's basically like this:

const useStyles = exposeStyles({
  merge: {
    root: "Checkbox",
    li: "Checkbox__li",
  },
  replace: {
    input: "Checkbox__input",
    description: "Checkbox__description",
  },
});

const classes = useStyles(props.classes);
Enter fullscreen mode Exit fullscreen mode

exposeStyles accepts an object where I define which keys are mergeable/replaceable. it returns a function which, when invoked, should return an object containing all those keys, like so: classes.root . I just don't know how to type that returned function

Thread Thread
 
pffigueiredo profile image
Pedro Figueiredo

I'm not 100% sure if you want to use NestedKeyOf in this scenario, and neither I'm sure of a few implementation details of your example. But take a look at this example that I started, and try to play around with it a bit, if you don't get it right, send me message over Twitter and I will help you further ;)

Collapse
 
somu93 profile image
somu93

${Key}.${NestedKeyOf<ObjectType[Key]>}. this line gives me an error when typescript version is 4.6.4 & 4.7.4 (latest) ->

Type instantiation is excessively deep and possibly infinite. Can you explain why and can you please also add the return type.

Collapse
 
kemotx90 profile image
kemotx90

use

`${Key}.${NestedKeyOf<ObjectType[Key]> extends infer U extends string ? U : never}`
Enter fullscreen mode Exit fullscreen mode

instead of

${Key}.${NestedKeyOf<ObjectType[Key]>}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
skmohammadi profile image
skmohammadi

Your suggestion fixed the following TS error:

Type instantiation is excessively deep and possibly infinite.
Enter fullscreen mode Exit fullscreen mode
Collapse
 
jsonz1993 profile image
Jsonz

cool

Collapse
 
angeloryndon profile image
Angelo Ryndon

Thank you! will definitely use this helper at work :D

Collapse
 
codriniftimie profile image
Codrin Iftimie

If anyone is looking for a solution that works with objects that include arrays, which would make this article complete, I stumbled upon this localcoder.org/typescript-deep-key...

In here there is a brief mention of this file used in react-hook-form.

This is the version I ended up using. This also includes methods of getting the type of the key which would come in handy

Collapse
 
pabloimrik17 profile image
Pablo F. Guerra

Hi @codriniftimie, any chance you could update your type for array with this syntax?

Instead of a.b.1 --> a.b.[1]

I would help me a lot in my current project.

Collapse
 
dzey profile image
Jakub Królak

I found this solution that I think is simpler and easier to understand

type NestedKeyOf<T, K = keyof T> = K extends keyof T & (string | number)
  ? `${K}` | (T[K] extends object ? `${K}.${NestedKeyOf<T[K]>}` : never)
  : never
Enter fullscreen mode Exit fullscreen mode
Collapse
 
akib profile image
Shuaib hasan akib • Edited

Genius!