HiveBrain v1.2.0
Get Started
← Back to all entries
patterntypescriptCritical

Typescript: deep keyof of a nested object

Submitted by: @import:stackoverflow-api··
0
Viewed 0 times
typescriptobjectkeyofdeepnested

Problem

So I would like to find a way to have all the keys of a nested object.

I have a generic type that take a type in parameter. My goal is to get all the keys of the given type.

The following code work well in this case. But when I start using a nested object it's different.

type SimpleObjectType = {
a: string;
b: string;
};

// works well for a simple object
type MyGenericType = {
keys: Array;
};

const test: MyGenericType = {
keys: ['a'];
}


Here is what I want to achieve but it doesn't work.

type NestedObjectType = {
a: string;
b: string;
nest: {
c: string;
};
otherNest: {
c: string;
};
};

type MyGenericType = {
keys: Array;
};

// won't works => Type 'string' is not assignable to type 'a' | 'b' | 'nest' | 'otherNest'
const test: MyGenericType = {
keys: ['a', 'nest.c'];
}


So what can I do, without using function, to be able to give this kind of keys to test ?

Solution

Currently the simplest way to do this without worrying about edge cases looks like

type Paths = T extends object ? { [K in keyof T]:
  `${Exclude}${"" | `.${Paths}`}`
}[keyof T] : never

type Leaves = T extends object ? { [K in keyof T]:
  `${Exclude}${Leaves extends never ? "" : `.${Leaves}`}`
}[keyof T] : never


which produces

type NestedObjectType = {
  a: string; b: string;
  nest: { c: string; };
  otherNest: { c: string; };
};

type NestedObjectPaths = Paths
// type NestedObjectPaths = "a" | "b" | "nest" | 
//   "otherNest" | "nest.c" | "otherNest.c"

type NestedObjectLeaves = Leaves
// type NestedObjectLeaves = "a" | "b" | "nest.c" | "otherNest.c"


Playground link to code

UPDATE for TS4.1 It is now possible to concatenate string literals at the type level, using template literal types as implemented in microsoft/TypeScript#40336. The below implementation can be tweaked to use this instead of something like Cons (which itself can be implemented using variadic tuple types as introduced in TypeScript 4.0):
type Join = K extends string | number ?
P extends string | number ?
${K}${"" extends P ? "" : "."}${P}
: never : never;


Here Join concatenates two strings with a dot in the middle, unless the last string is empty. So Join is "a.b.c" while Join is "a".

Then Paths and Leaves become:

type Paths = [D] extends [never] ? never : T extends object ?
    { [K in keyof T]-?: K extends string | number ?
        `${K}` | Join>
        : never
    }[keyof T] : ""

type Leaves = [D] extends [never] ? never : T extends object ?
    { [K in keyof T]-?: Join> }[keyof T] : "";


And the other types fall out of it:

type NestedObjectPaths = Paths;
// type NestedObjectPaths = "a" | "b" | "nest" | "otherNest" | "nest.c" | "otherNest.c"
type NestedObjectLeaves = Leaves
// type NestedObjectLeaves = "a" | "b" | "nest.c" | "otherNest.c"


and

type MyGenericType = {
    keys: Array>;
};

const test: MyGenericType = {
    keys: ["a", "nest.c"]
}


The rest of the answer is basically the same. Recursive conditional types (as implemented in microsoft/TypeScript#40002) will be supported in TS4.1 also, but recursion limits still apply so you'd have a problem with tree-like structures without a depth limiter like Prev.

PLEASE NOTE that this will make dotted paths out of non-dottable keys, like {foo: [{"bar-baz": 1}]} might produce foo.0.bar-baz. So be careful to avoid keys like that, or rewrite the above to exclude them.

ALSO PLEASE NOTE: these recursive types are inherently "tricky" and tend to make the compiler unhappy if modified slightly. If you're not lucky you will see errors like "type instantiation is excessively deep", and if you're very unlucky you will see the compiler eat up all your CPU and never complete type checking. I'm not sure what to say about this kind of problem in general... just that such things are sometimes more trouble than they're worth.

Playground link to code

PRE-TS4.1 ANSWER:

As mentioned, it is not currently possible to concatenate string literals at the type level. There have been suggestions which might allow this, such as a suggestion to allow augmenting keys during mapped types and a suggestion to validate string literals via regular expression, but for now this is not possible.

Instead of representing paths as dotted strings, you can represent them as tuples of string literals. So "a" becomes ["a"], and "nest.c" becomes ["nest", "c"]. At runtime it's easy enough to convert between these types via split() and join() methods.

So you might want something like Paths that returns a union of all the paths for a given type T, or possibly Leaves which is just those elements of Paths which point to non-object types themselves. There is no built-in support for such a type; the ts-toolbelt library has this, but since I can't use that library in the Playground, I will roll my own here.

Be warned: Paths and Leaves are inherently recursive in a way that can be very taxing on the compiler. And recursive types of the sort needed for this are not officially supported in TypeScript either. What I will present below is recursive in this iffy/not-really-supported way, but I try to provide a way for you to specify a maximum recursion depth.

Here we go:

type Cons = T extends readonly any[] ?
    ((h: H, ...t: T) => void) extends ((...r: infer R) => void) ? R : never
    : never;

type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
    11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...0[]]

type Paths = [D] extends [never] ? never : T extends object ?
    { [K in keyof T]-?: [K] | (Paths extends infer P ?
        P extends [] ? never : Cons : never
    ) }[keyof T]
    : [];

type Leaves = [D] extends [never] ? never : T extends object ?
    { [K in keyof T]-?: Cons> }[keyof T]
    : [];


The intent of Cons is to take any type H and a tuple-type T and produce a new tuple with H pr

Code Snippets

type Paths<T> = T extends object ? { [K in keyof T]:
  `${Exclude<K, symbol>}${"" | `.${Paths<T[K]>}`}`
}[keyof T] : never

type Leaves<T> = T extends object ? { [K in keyof T]:
  `${Exclude<K, symbol>}${Leaves<T[K]> extends never ? "" : `.${Leaves<T[K]>}`}`
}[keyof T] : never
type NestedObjectType = {
  a: string; b: string;
  nest: { c: string; };
  otherNest: { c: string; };
};

type NestedObjectPaths = Paths<NestedObjectType>
// type NestedObjectPaths = "a" | "b" | "nest" | 
//   "otherNest" | "nest.c" | "otherNest.c"

type NestedObjectLeaves = Leaves<NestedObjectType>
// type NestedObjectLeaves = "a" | "b" | "nest.c" | "otherNest.c"
type Paths<T, D extends number = 10> = [D] extends [never] ? never : T extends object ?
    { [K in keyof T]-?: K extends string | number ?
        `${K}` | Join<K, Paths<T[K], Prev[D]>>
        : never
    }[keyof T] : ""

type Leaves<T, D extends number = 10> = [D] extends [never] ? never : T extends object ?
    { [K in keyof T]-?: Join<K, Leaves<T[K], Prev[D]>> }[keyof T] : "";
type NestedObjectPaths = Paths<NestedObjectType>;
// type NestedObjectPaths = "a" | "b" | "nest" | "otherNest" | "nest.c" | "otherNest.c"
type NestedObjectLeaves = Leaves<NestedObjectType>
// type NestedObjectLeaves = "a" | "b" | "nest.c" | "otherNest.c"
type MyGenericType<T extends object> = {
    keys: Array<Paths<T>>;
};

const test: MyGenericType<NestedObjectType> = {
    keys: ["a", "nest.c"]
}

Context

Stack Overflow Q#58434389, score: 290

Revisions (0)

No revisions yet.