I'm only going to consider the type-level transformation and not issues about type inference or any sort of implementation. My goal here is to write a utility type toTree<T>
such that given a "flat" data structure like
type Flat = {
key1: { property: "value1" },
key2: { property: "value2" },
key3: { property: "value3", parentKey: "key2" },
key4: { property: "value4", parentKey: "key3" }
};
the transformed type ToTree<Flat>
is the corresponding tree structure:
type Tree = ToTree<Flat>;
/* type Tree = {
key1: { property: "value1"; };
key2: {
property: "value2";
key3: {
property: "value3";
key4: { property: "value4"; };
};
};
*/
This is necessarily going to be a recursive conditional type, which tend to have interesting edge cases. And if the edge-case behavior of a recursive conditional type isn't desirable, it sometimes requires significant refactoring to account for it. So be warned.
My approach looks like this. First I'm going to write an intermediate utility type ToTreeInner<T>
that assumes every property in T
has a parentKey
property which is either a key or undefined
. So instead of { key1: { property: "value1" }}
, it would need { key1: { property: "value1", parentKey: undefined }}
. This just makes it a lot more straightforward to implement, since we can always just grab the parentKey
property without having to write logic for dealing with missing keys every time. Then if we have ToTreeInner
, we can write ToTree
like this:
type ToTree<T extends Record<keyof T, {
parentKey?: PropertyKey, [k: string]: any
}>> = ToTreeInner<{ [K in keyof T]: T[K] & (
"parentKey" extends keyof T[K] ? unknown : { parentKey: undefined }
) }>;
That allows each property of T
to optionally have a parentKey
property (and I've added a catch-all index signature so as to not run into weak type detection). It then adds an undefined
value for parentKey
to any property that doesn't have such a property, before passing it to ToTreeInner
.
So now let's implement ToTreeInner
:
type ToTreeInner<
T extends Record<keyof T, { parentKey: PropertyKey | undefined }>,
P extends PropertyKey | undefined = undefined
> = { [K in keyof T as P extends T[K]["parentKey"] ? K : never]:
(Omit<T[K], "parentKey"> & ToTreeInner<T, K>) extends
infer O ? { [K in keyof O]: O[K] } : never
};
This type function takes two type arguments. The type T
is the full flat structure, while P
is the name of the parent key for the current node in the output tree. This can be undefined
if we're at the root of the tree, and it defaults to undefined
so you can leave out that type argument to get the full tree.
Then we have a key-remapped type { [K in keyof T as P extends T[K]["parentKey"] ? K : never]: ⋯ }
which uses that as
clause to filter the keys. The output object will only have a key K
if P extends T[K]["parentKey"]
, meaning that it will only have a key K
if the corresponding property in T
has a parentKey
of P
.
And the value at key K
is essentially Omit<T[K], "parentKey"> & ToTreeInner<T, K>
. We use the Omit
utility type to strip the parentKey
property out (since we don't want the output to have parentKey
properties). And we intersect it with the recursive call ToTreeInner<T, K>
, meaning that we will also add a new tree right here, rooted at the current key K
.
That's basically the whole thing, but if you use that directly and display the output type you'd get an awful mess with nested Omit
s and intersections and DeepTreeInner
types. So I use a trick described at How can I see the full expanded contract of a Typescript type? to expand the type to a single property type. Instead of PossiblyUglyType
, I write PossiblyUglyType extends infer O ? { [K in keyof O]: O[K] } : never
, which "copies" PossiblyUglyType
into a new type parameter O
, and then walks over the properties of O
without modifying them. This is essentially a big no-op more or less, but it affects the display.
So let's try it:
type Tree = ToTree<Flat>;
/* type Tree = {
key1: { property: "value1"; };
key2: {
property: "value2";
key3: {
property: "value3";
key4: { property: "value4"; };
};
};
*/
Looks good. Let's just try something else:
type Test = ToTree<{
a: { b: string, c: number },
d: { e: boolean, parentKey: "a" },
f: { g: Date, parentKey: "a" },
h: { i: any, parentKey: "d" }
}>
/* type Test = {
a: {
b: string;
c: number;
d: {
e: boolean;
h: {
i: any;
};
};
f: {
g: Date;
};
};
} */
Also looks good!
Playground link to code