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
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){
...
}
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.
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.
- Go through the object's keys
- If the key's value is not an object , then it's a valid key
- 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' ]
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 = {};
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>;
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> = {};
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]};
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;
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" | ""
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];
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)];
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 👇
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
- Create a type that accepts a generic
- Constraint the generic to be an object
- Create a new object with the help of Mapped Types
- For each key, check if the value is an object or a primitive type
- If it's an object then concat the current key and call the type in a recursiveness manner
- 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)
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 asstring
?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 ;)
so it's basically like this:
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 functionI'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 ;)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>];
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 ❤️
${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.
use
instead of
Your suggestion fixed the following TS error:
cool
Thank you! will definitely use this helper at work :D
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
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.
I found this solution that I think is simpler and easier to understand
Genius!