Skip to content

TypeScript Tutorial: Extracting All Keys from Nested Object

Published: at 

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: stringid: string; name: stringname: string;
address: {
    street: string;
    city: string;
}
address
: {
street: stringstreet: string; city: stringcity: 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) KK in keyof function (type parameter) T in type Pretty<T>T]: function (type parameter) T in type Pretty<T>T[function (type parameter) KK];
} & {};

type type UserKeys = keyof UserUserKeys = 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 UserUserKeys>;

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:

  1. Conditional Types (if-then for types)
  2. Mapped Types (change each part of a type)
  3. Template Literal Types (make new string types)
  4. 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] : neverExtractKeys<function (type parameter) T in type ExtractKeys<T>T> = function (type parameter) T in type ExtractKeys<T>T extends object
  ? {
      [function (type parameter) KK in keyof function (type parameter) T in type ExtractKeys<T>T & string]: 
        | function (type parameter) KK 
        | (function (type parameter) T in type ExtractKeys<T>T[function (type parameter) KK] extends object ? `${function (type parameter) KK}.${type ExtractKeys<T> = T extends object ? { [K in keyof T & string]: K | (T[K] extends object ? `${K}.${ExtractKeys<T[K]>}` : K); }[keyof T & string] : neverExtractKeys<function (type parameter) T in type ExtractKeys<T>T[function (type parameter) KK]>}` : function (type parameter) KK);
    }[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:

  1. We check if T is an object.
  2. If it is, we look at each key.
  3. For each key, we either keep it as-is or…
  4. If the key’s value is another object, we add the key, a dot, and all the keys inside it.
  5. 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] : neverExtractKeys<
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: Useruser: 
type User = {
    id: string;
    name: string;
    address: {
        street: string;
        city: string;
    };
}
User
= {
id: stringid: "123", name: stringname: "John Doe",
address: {
    street: string;
    city: string;
}
address
: {
street: stringstreet: "Main St", city: stringcity: "Berlin", }, }; function function getProperty(obj: User, key: UserKeys): anygetProperty(obj: Userobj:
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.
@paramseparator A string that identifies character or characters to use in separating the string. If omitted, a single-element array containing the entire string is returned.@paramlimit A value used to limit the number of elements returned in the array.
split
(".");
let let result: anyresult: any = obj: Userobj; for (const const k: stringk of const keys: string[]keys) { let result: anyresult = let result: anyresult[const k: stringk]; } return let result: anyresult; } // This works function getProperty(obj: User, key: UserKeys): anygetProperty(const user: Useruser, "address.street"); // This gives an error function getProperty(obj: User, key: UserKeys): anygetProperty(const user: Useruser, "address.country");
Argument of type '"address.country"' is not assignable to parameter of type '"id" | "name" | "address" | "address.street" | "address.city"'.

TypeScript catches your mistakes before they blow up in your face.

Remember

  1. This type can be slow for very big objects.
  2. It doesn’t change how your code runs. It only helps catch errors early.
  3. 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.