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 provide this functionality out of the box.
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”. The standard approach is insufficient:
// 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 approach returns the top-level keys, missing nested properties like “address.street”.
We need a more sophisticated solution using TypeScript’s advanced features:
- 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)
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;
Let’s break down this type definition:
- We check if T is an object.
- For each key in the object:
- We either preserve the key as-is, or
- If the key’s value is another object, we combine the key with its nested keys
- We apply this to the entire type structure
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>;
This gives us all keys, including nested ones.
The practical benefits become clear in this example:
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 detects potential errors during development.
Important Considerations:
- This type implementation may impact performance with complex nested objects.
- The type system enhances development-time safety without runtime overhead.
- Consider the trade-off between type safety and code readability.
Wrap-Up
We’ve explored how to extract all keys from nested TypeScript objects. This technique provides enhanced type safety for your data structures. Consider the performance implications when implementing this in your projects.