Your RelationsOf<T>
is similar in spirit to Paths<T>
as described in the answer to a similar question. Like Paths<T>
, such deeply nested types are tricky and fragile, and can easily cause the compiler to complain about instantiation depth (if you're lucky) or get completely bogged down and unresponsive (if you're unlucky). For any recursive type, the number of potential paths through an object type tends to be exponential in the path depth. Depth limiting is therefore a good idea, although sometimes even with depth limiting there are problems with compiler performance.
Here's one way to implement RelationsOf<T, D>
where D
is an optional numeric literal type corresponding to the desired depth (if you don't specify D
then it defaults to 3
):
type RelationsOf<T extends object, D extends number = 3> =
[D] extends [0] ? never :
T extends object[] ? RelationsOf<T[number], D> :
keyof T extends infer K ?
K extends string & keyof T ?
T[K] extends object ?
K | `${K}.${RelationsOf<T[K], Prev[D]>}` :
never : never : never;
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
The Prev
type is a helper so that the compiler can easily subtract one from anything you give as D
(e.g., Prev[10]
is 9
); it currently stops at 15
. You could make it longer, but you'll probably run into depth problems.
The way it works: to compute RelationsOf<T, D>
we first check to see if D
is 0
. If so, we bail out. Otherwise we check to see if T
is an array of objects. If so, we compute RelationsOf<T[number], D>
where T[number]
is the element type of the T
array. (We do this to skip one level of depth corresponding to the keys of arrays; otherwise you'd end up with paths like `tags.${number}.companies`
instead of "tags.companies"
.)
At this point we use a distributive conditional type to take keyof T
and perform a type operation for each string union member K
. So if keyof T
is "name" | "country" | "tags"
, then K
will be "name"
, "country"
, and "tags"
in turn, and the result of our type operation will be joined in a union.
The type operation is: if the property type of T
at key K
(T[K]
) is an object, then we compute K | `${K}.${RelationsOf<T[K], Prev[D]>}`
. Meaning, we take the key itself plain, and we prepend it (and a dot) to the results of RelationsOf
operating on the property type with the depth reduced by one. If T[K]
isn't an object, then we use never
, since we don't want that key in our output type.
Let's test it out:
type CompanyRelations = RelationsOf<Company>;
/* type CompanyRelations = "country" | "tags" | "tags.companies" |
"tags.countries" | "country.tags" | "country.companies" |
"country.tags.companies" | "country.tags.countries" |
"country.companies.country" | "country.companies.tags" |
"tags.companies.country" | "tags.companies.tags" |
"tags.countries.tags" | "tags.countries.companies" */
type CountryRelations = RelationsOf<Country>;
/* type CountryRelations = "companies" | "tags" | "tags.companies" |
"tags.countries" | "companies.tags" | "companies.country" |
"companies.tags.companies" | "companies.tags.countries" |
"companies.country.companies" | "companies.country.tags" |
"tags.companies.tags" | "tags.companies.country" |
"tags.countries.companies" | "tags.countries.tags" */
That looks right. Everything has a path length of at most 3. If we increase D
, we see how the union gets longer (with the paths getting longer too):
type CompanyRelations4 = RelationsOf<Company, 4>
/* type CompanyRelations4 = "country" | "tags" | "tags.companies" |
"tags.countries" | "tags.companies.country" | "tags.companies.tags" |
"tags.countries.tags" | "tags.countries.companies" | "country.tags" |
"country.companies" | "country.companies.country" |
"country.companies.tags" | "country.tags.companies" |
"country.tags.countries" | "country.tags.companies.country" |
"country.tags.companies.tags" | "country.tags.countries.tags" |
"country.tags.countries.companies" | "country.companies.tags.companies" |
"country.companies.tags.countries" | "country.companies.country.tags" |
"country.companies.country.companies" | "tags.companies.tags.companies" |
"tags.companies.tags.countries" | "tags.companies.country.tags" |
"tags.companies.country.companies" | "tags.countries.companies.country" |
"tags.countries.companies.tags" | "tags.countries.tags.companies" |
"tags.countries.tags.countries" */
If we try 10
the compile won't even list them out individually because there are more than 2000 entries;
type CompanyRelations10 = RelationsOf<Company, 10>;
/* type CompanyRelations10 = "country" | "tags" | "tags.companies" |
"tags.countries" | "tags.companies.country" | "tags.companies.tags" |
"tags.countries.tags" | "tags.countries.companies" | "country.tags" |
... 2036 more ... |
"tags.countries.tags.countries.companies.country.companies.country.tags.countries"
*/
Okay, looks good.
Playground link to code