Let us first define two utility types that will be used:
type SplitPath<S, R extends unknown[] = []> = S extends `${infer P}.${infer Rest}` ? SplitPath<Rest, [...R, P]> : [...R, S];
type JoinPath<P, S extends string = ""> = P extends [infer First, ...infer Rest] ? JoinPath<Rest, `${S}${S extends "" ? "" : "."}${First & string}`> : S;
These types simply split and join paths (opposites of each other).
SplitPath<"country.tags.companies"> would give me a tuple ["country", "tags", "companies"]
JoinPath<["country", "tags", "companies"]> would give me a string "country.tags.companies"
Then a type to recursively expand the given type for debugging:
// Type to expand results so we can see if it works (mostly for debugging)
// WARNING: BREAKS ON TUPLES
type Expand<T> = T extends ReadonlyArray<unknown> ? Expand<T[number]>[] : T extends object ? { [K in keyof T]: Expand<T[K]> } : T;
Without it, when we try to see results of Populate, it doesn't show the result, but actually Populate<Company, [...]>, which is unhelpful. With this type it expands Populate<...> into its full form that we can see.
Now we define a simple find function to test Populate:
declare function find<Relations extends ReadonlyArray<string> = never>(criteria: {
id?: number;
relations?: Relations;
// ...
}): Expand<Populate<Company, Relations>>;
Because relations is optional I added the default value of never for the Relations generic parameter. Then when you don't provide relations Populate has no effect.
After we populate Company, we expand it into its full form.
And here's Populate, which seems daunting at first:
type Populate<T, Keys extends ReadonlyArray<string>> = Omit<T, SplitPath<Keys[number]>[0]> & {
[K in SplitPath<Keys[number]>[0]]-?:
NonNullable<T[K & keyof T]> extends ReadonlyArray<unknown>
? Populate<NonNullable<T[K & keyof T]>[number], OmitFirstLevel<Keys, K>>[]
: NonNullable<Populate<NonNullable<T[K & keyof T]>, OmitFirstLevel<Keys, K>>>
};
First we omit all properties from T that are affected because we'll add the affected properties later:
Omit<T, SplitPath<Keys[number]>[0]>
Let's use a simple example Populate<Company, ["country", "country.tags"]> to walk through how Populate works and simplify this Omit usage:
Omit<Company, SplitPath<["country", "country.tags"][number]>[0]>
Omit<Company, SplitPath<"country" | "country.tags">[0]>
Omit<Company, (["country"] | ["country", "tags"])[0]>
Omit<Company, "country">
After we omit all affected first-level keys, we intersect this with a mapped type to add them back:
{
[K in SplitPath<Keys[number]>[0]]-?:
NonNullable<T[K & keyof T]> extends ReadonlyArray<unknown>
? Populate<NonNullable<T[K & keyof T]>[number], OmitFirstLevel<Keys, K>>[]
: NonNullable<Populate<NonNullable<T[K & keyof T]>, OmitFirstLevel<Keys, K>>>
}
And again, to understand what we're doing here, let's simplify this step-by-step. So first like above, we get the affected first-level keys and map over them:
{
[K in "country"]-?:
NonNullable<T[K & keyof T]> extends ReadonlyArray<unknown>
? Populate<NonNullable<T[K & keyof T]>[number], OmitFirstLevel<Keys, K>>[]
: NonNullable<Populate<NonNullable<T[K & keyof T]>, OmitFirstLevel<Keys, K>>>
}
-? is making this property required. Without it, it would still be optional.
So now, the mapped type simplifies to:
{
country: NonNullable<T["country" & keyof T]> extends ReadonlyArray<unknown>
? Populate<NonNullable<T["country" & keyof T]>[number], OmitFirstLevel<Keys, "country">>[]
: NonNullable<Populate<NonNullable<T["country" & keyof T]>, OmitFirstLevel<Keys, "country">>>
}
Now, "country" & keyof T might seem repetitive and useless, but actually without & keyof T it will throw errors that K cannot be used to index type T. You can think of & keyof T as an assertion that K is a key of T. So this simplifies further to:
{
country: NonNullable<T["country"]> extends ReadonlyArray<unknown>
? Populate<NonNullable<T["country"]>[number], OmitFirstLevel<Keys, "country">>[]
: NonNullable<Populate<NonNullable<T["country"]>, OmitFirstLevel<Keys, "country">>>
}
It's not looking so bad now, but what's with all the NonNullable uses everywhere? Because T["country"] is optional, it can be undefined. We don't want undefined while operating on it, so we exclude it with NonNullable. An alternative would be Exclude<T["country"], undefined> but NonNullable is shorter and more explicit.
Let's remove NonNullable here for brevity so we can see what's really happening:
{
country: T["country"] extends ReadonlyArray<unknown>
? Populate<T["country"][number], OmitFirstLevel<Keys, "country">>[]
: Populate<T["country"], OmitFirstLevel<Keys, "country">>>
}
Alright, so now it's pretty easy to tell what's happening.
We check if T["country"] is an array, and if it is, we populate the type of an element of the array and put that back in an array, otherwise, we just populate T["country"].
But with what keys? That's what OmitFirstLevel does. I've hidden it until now because it's quite a mess and could be cleaned up a bit, but here it is as of now:
type OmitFirstLevel<
Keys,
Target extends string,
R extends ReadonlyArray<unknown> = [],
> = Keys extends readonly [infer First, ...infer Rest]
? SplitPath<First> extends readonly [infer T, ...infer Path]
? T extends Target
? Path extends []
? OmitFirstLevel<Rest, Target, R>
: OmitFirstLevel<Rest, Target, [...R, JoinPath<Path>]>
: OmitFirstLevel<Rest, Target, R>
: OmitFirstLevel<Rest, Target, R>
: R
;
It's probably easier to explain with some examples:
OmitFirstLevel<["country", "country.tags"], "country"> gives ["tags"]
OmitFirstLevel<["country", "country.tags", "country.tags.companies"], "country"> gives ["tags", "tags.companies"]
OmitFirstLevel<["country", "country.tags", "tags", "tags.companies"], "country"> gives ["tags"]
You can probably see that it just gets all paths that start with the given value and then strips the given value off of the path.
Now then, to simplify our mapped type even further still:
{
country: T["country"] extends ReadonlyArray<unknown>
? Populate<T["country"][number], ["tags"]>>[]
: Populate<T["country"], ["tags"]>>
}
Because T["country"] is in fact not an array it simplifies down to:
{
country: Populate<T["country"], ["tags"]>
}
And oh look! It populates T["country"]'s tags property for us! That's nice! So that's how this entire plate of spaghetti functions. If you want more detail on how JoinPath, SplitPath, or OmitFirstLevel works, mention me and I'll revise this post to include some.
Playground