What’s the Problem?
Let’s say you have a big TypeScript object. It has objects inside objects. You want to get all the keys, even the nested ones. But TypeScript doesn’t make this easy.
Look at this User object:
type type User = {
id: string;
name: string;
address: {
street: string;
city: string;
};
}
User = {
id: string
id: string;
name: string
name: string;
address: {
street: string;
city: string;
}
address: {
street: string
street: string;
city: string
city: string;
};
};
You want “id”, “name”, and “address.street”. But TypeScript just shrugs. The usual way? It’s useless:
// little helper to prettify the type on hover
type type Pretty<T> = { [K in keyof T]: T[K]; }
Pretty<function (type parameter) T in type Pretty<T>
T> = {
[function (type parameter) K
K in keyof function (type parameter) T in type Pretty<T>
T]: function (type parameter) T in type Pretty<T>
T[function (type parameter) K
K];
} & {};
type type UserKeys = keyof User
UserKeys = keyof type User = {
id: string;
name: string;
address: {
street: string;
city: string;
};
}
User;
type type PrettyUserKeys = "id" | "name" | "address"
PrettyUserKeys = type Pretty<T> = { [K in keyof T]: T[K]; }
Pretty<type UserKeys = keyof User
UserKeys>;
This only gives you the top-level keys. It misses the nested goodies like “address.street”.
So, we need to get clever. We’ll use some TypeScript magic:
- Conditional Types (if-then for types)
- Mapped Types (change each part of a type)
- Template Literal Types (make new string types)
- Recursive Types (types that refer to themselves)
Don’t panic. It’s not as scary as it sounds.
Here’s our solution:
type type ExtractKeys<T> = T extends object ? { [K in keyof T & string]: K | (T[K] extends object ? `${K}.${ExtractKeys<T[K]>}` : K); }[keyof T & string] : never
ExtractKeys<function (type parameter) T in type ExtractKeys<T>
T> = function (type parameter) T in type ExtractKeys<T>
T extends object
? {
[function (type parameter) K
K in keyof function (type parameter) T in type ExtractKeys<T>
T & string]:
| function (type parameter) K
K
| (function (type parameter) T in type ExtractKeys<T>
T[function (type parameter) K
K] extends object ? `${function (type parameter) K
K}.${type ExtractKeys<T> = T extends object ? { [K in keyof T & string]: K | (T[K] extends object ? `${K}.${ExtractKeys<T[K]>}` : K); }[keyof T & string] : never
ExtractKeys<function (type parameter) T in type ExtractKeys<T>
T[function (type parameter) K
K]>}` : function (type parameter) K
K);
}[keyof function (type parameter) T in type ExtractKeys<T>
T & string]
: never;
Yes, it looks like a cat walked on your keyboard. But it works. Let’s break it down:
- We check if T is an object.
- If it is, we look at each key.
- For each key, we either keep it as-is or…
- If the key’s value is another object, we add the key, a dot, and all the keys inside it.
- We do this for all keys.
Now let’s use it:
type type UserKeys = "id" | "name" | "address" | "address.street" | "address.city"
UserKeys = type ExtractKeys<T> = T extends object ? { [K in keyof T & string]: K | (T[K] extends object ? `${K}.${ExtractKeys<T[K]>}` : K); }[keyof T & string] : never
ExtractKeys<type User = {
id: string;
name: string;
address: {
street: string;
city: string;
};
}
User>;
Voila! We’ve got all the keys, even the nested ones.
Why bother? It makes your code safer. Look:
const const user: User
user: type User = {
id: string;
name: string;
address: {
street: string;
city: string;
};
}
User = {
id: string
id: "123",
name: string
name: "John Doe",
address: {
street: string;
city: string;
}
address: {
street: string
street: "Main St",
city: string
city: "Berlin",
},
};
function function getProperty(obj: User, key: UserKeys): any
getProperty(obj: User
obj: type User = {
id: string;
name: string;
address: {
street: string;
city: string;
};
}
User, key: "id" | "name" | "address" | "address.street" | "address.city"
key: type UserKeys = "id" | "name" | "address" | "address.street" | "address.city"
UserKeys) {
const const keys: string[]
keys = key: "id" | "name" | "address" | "address.street" | "address.city"
key.String.split(separator: string | RegExp, limit?: number): string[] (+1 overload)
Split a string into substrings using the specified separator and return them as an array.split(".");
let let result: any
result: any = obj: User
obj;
for (const const k: string
k of const keys: string[]
keys) {
let result: any
result = let result: any
result[const k: string
k];
}
return let result: any
result;
}
// This works
function getProperty(obj: User, key: UserKeys): any
getProperty(const user: User
user, "address.street");
// This gives an error
function getProperty(obj: User, key: UserKeys): any
getProperty(const user: User
user, "address.country");
TypeScript catches your mistakes before they blow up in your face.
Remember
- This type can be slow for very big objects.
- It doesn’t change how your code runs. It only helps catch errors early.
- It can make your code harder to read. Use it wisely.
Wrap-Up
We’ve learned to wrangle all the keys from nested TypeScript objects. It’s like having x-ray vision for your data. But remember, with great power comes great responsibility. And slower compile times.