Useful TypeScript generics for tree structures
Source: Dev.to

Developers are surrounded by hierarchies of objects, such as the DOM tree, React component tree, and NPM dependency tree. Tree‑like data structures are quite common in code, and reliable TypeScript types make working with them more confident. In this article, I want to share with you some generics that benefit me the most.
Deep partial
Sometimes we are willing to provide default values for all parameters of a third‑party function, even nested ones, so they are all optional. In that case DeepPartial type is helpful:
type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;
type InitialType = {
header: {
size: 'sm' | 'md' | 'lg';
color: 'primary' | 'secondary';
nav: {
align: 'left' | 'right';
fontSize: number;
};
};
footer: {
fixed: boolean;
links: {
max: 5 | 10;
nowrap: boolean;
};
};
};
type ResultType = DeepPartial<InitialType>;
/*
type ResultType = {
header?: {
size?: "sm" | "md" | "lg" | undefined;
color?: "primary" | "secondary" | undefined;
nav?: {
align?: "left" | "right" | undefined;
fontSize?: number | undefined;
} | undefined;
} | undefined;
footer?: {
fixed?: boolean | undefined;
links?: {
max?: 5 | 10 | undefined;
nowrap?: boolean | undefined;
} | undefined;
} | undefined;
};
*/
Paths
Another case is that you need to infer all the available tree paths as a type. Paths generic can solve this:
type Paths<T, Exclude extends string = ''> = T extends object
? {
[K in keyof T]: `${Exclude}${'' | Paths<T[K], \`\${Exclude}\${K & string}\`> extends '' ? '' : \`.${Paths<T[K], \`\${Exclude}\${K & string}\`>}\`}`;
}[keyof T]
: never;
type InitialType = {
header: {
size: 'sm' | 'md' | 'lg';
color: 'primary' | 'secondary';
nav: {
align: 'left' | 'right';
fontSize: number;
};
};
footer: {
fixed: boolean;
links: {
max: 5 | 10;
nowrap: boolean;
};
};
};
type ResultType = Paths<InitialType>;
/*
type ResultType =
| "header.size"
| "header.color"
| "header.nav.align"
| "header.nav.fontSize"
| "footer.fixed"
| "footer.links.max"
| "footer.links.nowrap";
*/
Including leaves
Sometimes the path should include leaves, so you can use the next generic:
type Paths<T, Exclude extends string = ''> = T extends object
? {
[K in keyof T]: `${Exclude}${'' | Paths<T[K], \`\${Exclude}\${K & string}\`> extends '' ? '' : \`.${Paths<T[K], \`\${Exclude}\${K & string}\`>}\`}`;
}[keyof T]
: T extends string | number | boolean
? T
: never;
type InitialType = {
header: {
size: 'sm' | 'md' | 'lg';
color: 'primary' | 'secondary';
nav: {
align: 'left' | 'right';
fontSize: number;
};
};
footer: {
fixed: boolean;
links: {
max: 5 | 10;
nowrap: boolean;
};
};
};
type ResultType = Paths<InitialType>;
/*
type ResultType =
| "header.size.sm"
| "header.size.md"
| "header.size.lg"
| "header.color.primary"
| "header.color.secondary"
| "header.nav.align.left"
| "header.nav.align.right"
| `header.nav.fontSize.${number}`
| "footer.fixed.false"
| "footer.fixed.true"
| "footer.links.max.5"
| "footer.links.max.10"
| "footer.links.nowrap.false"
| "footer.links.nowrap.true";
*/
You may notice a strange element header.nav.fontSize.${number} in the ResultType. It appears because the fontSize parameter can take an infinite number of values. We can modify the generic to eliminate such paths:
type Paths<T, Exclude extends string = ''> = T extends object
? {
[K in keyof T]: `${Exclude}${'' | Paths<T[K], \`\${Exclude}\${K & string}\`> extends '' ? '' : \`.${Paths<T[K], \`\${Exclude}\${K & string}\`>}\`}`;
}[keyof T]
: T extends string | number | boolean
? `${number}` extends `${T}`
? never
: T
: never;
type InitialType = {
header: {
size: 'sm' | 'md' | 'lg';
color: 'primary' | 'secondary';
nav: {
align: 'left' | 'right';
fontSize: number;
};
};
footer: {
caption: string;
fixed: boolean;
links: {
max: 5 | 10;
nowrap: boolean;
};
};
};
type ResultType = Paths<InitialType>;
/*
type ResultType =
| "header.size.sm"
| "header.size.md"
| "header.size.lg"
| "header.color.primary"
| "header.color.secondary"
| "header.nav.fontSize"
| "header.nav.align.left"
| "header.nav.align.right"
| "footer.caption"
| "footer.fixed.false"
| "footer.fixed.true"
| "footer.links.max.5"
| "footer.links.max.10"
| "footer.links.nowrap.false"
| "footer.links.nowrap.true";
*/
Nodes and Leaves
In many algorithms, it is necessary to distinguish the nodes of the tree from the leaves. If we consider those parameters that have object values as nodes, then we can apply the following generics:
type Nodes<T, Exclude extends string = ''> = T extends object
? {
[K in keyof T]: T[K] extends object
? `${Exclude}${K & string}` | `${Exclude}${K & string}.${Nodes<T[K], \`\${Exclude}\${K & string}\`>}`
: never;
}[keyof T]
: never;
type Leaves<T, Exclude extends string = ''> = T extends object
? {
[K in keyof T]: `${Exclude}${K & string}${Leaves<T[K], \`\${Exclude}\${K & string}\`> extends never ? '' : \`.${Leaves<T[K], \`\${Exclude}\${K & string}\`>}\`}`;
}[keyof T]
: never;
(The Leaves definition is truncated in the original source.)
TypeScript Example
type InitialType = {
header: {
size: 'sm' | 'md' | 'lg';
color: 'primary' | 'secondary';
nav: {
align: 'left' | 'right';
fontSize: number;
};
};
footer: {
caption: string;
fixed: boolean;
links: {
max: 5 | 10;
nowrap: boolean;
};
};
};
type ResultNodes = Nodes<InitialType>;
type ResultLeaves = Leaves<InitialType>;
/*
type ResultNodes = "header" | "footer" | "header.nav" | "footer.links";
type ResultLeaves = "header.size" | "header.color" | "header.nav.align" |
"header.nav.fontSize" | "footer.fixed" | "footer.caption" |
"footer.links.max" | "footer.links.nowrap";
*/
Fullscreen Controls (illustrative)
Enter fullscreen mode
Exit fullscreen mode
Conclusion
As you can see, TypeScript has the necessary flexibility to simplify your code. And although tree structures seem to be quite complex, generics help to cope with them without types duplication. I hope that you have found something interesting in this article.
Enjoy your frontend development!