22
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.
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.
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.
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.
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.
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.
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)];
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 🤯
- 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 🙏
22