3

I am attempting to create a Populate type that takes 2 generics: object type with optional relations (keys that reference other object types), and a union of Path strings that can deeply populate (or rather, set as non-optional) the relations. For example: having 3 entities that all optionally reference each other:

type Tag = { name: string, countries?: Country[], companies?: Company[] }
type Company = { name: string, country?: Country, tags?: Tag[]  };
type Country = { name: string, companies?: Company[], tags?: Tag[] };

type Populate = { // ... need help here ... // }
type CompanyWithCountry = Populate<Company, 'country'>
type CompanyWithCountryAndTags = Populate<Company, 'country' | 'tags' | 'country.tags'>
type SuperPopulatedCompany = Populate<Company, 'country' | 'country.tags', 'country.tags.companies' | 'tags' | 'tags.companies' | 'tags.countries'>

/** result for SuperPopulatedCompany = {
 name: string; 
 country: null | Populate<Country, 'tags' | 'tags.companies'>, //note for non-array items `null would be possible`
 tags: Populate<Tag, 'companies' | 'countries'>[], //for array relations at least an empty array is always returned
} 
*/

The purpose of this is to enable me to type resulting object of using TypeORM's relations key in some of my queries, which can populate relational objects that reference eachother. Unfortunately, TypeORM's return type is always a base entity type, and any relations always remain optional regardless of which relations you passed into the query. I'm wanting to cast the return type to make relations not optional if they were queried. For example:

const company = companyRepository.find({ 
    id: 1,
    relations: ['country', 'country.companies', 'country.tags', 'tags', 'tags.companies'],
}) as Populate<Company, 'country' | 'country.companies' | 'country.tags' | 'tags' | 'tags.companies' >

/*
typeof company = {
  name: string;
  country: null | {
    name: string;
    companies: Company[], //non-optional
    tags: Tags[], //non-optional
  }
  tags: { 
    name: string; 
    companies: Company[],  // non-optional
    countries?: Country[], //optional (because not in relations query)
  }[]
}

Allowing me to access: 
 company.country, 
 company.country?.companies, 
 company.country?.tags, 
 company.tags, 
 company.tags[n]?.companies
*/
    
3
  • To clear up why some keys may be null vs empty array: In a lot of relational ORMs if the JOINable row on a one-to-one relation is not found, the result will be null. If no JOINable rows are found on a one-to-many relation, the result is an empty array. Commented Apr 20, 2022 at 22:21
  • 1
    After an hour of fiddling I made this monstrosity. There might be a few little kinks I have to roll out but if it works for you, I'll find the time to write a complete answer. Commented Apr 20, 2022 at 22:50
  • @Gisheri could you please provide more explanation of what you are trying to achieve? At least minimum reproducible example with two properties Commented Apr 21, 2022 at 18:24

1 Answer 1

1

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

Sign up to request clarification or add additional context in comments.

2 Comments

this is great! Thanks for looking over this problem. I'm just trying it out. The only difference I notice in the output is that nested populated children still have optional in their relations rather than null. I'm looking through it trying to see if I can figure out where that can be changed.
@Gisheri I don't know what that specifically means but this is a version that uses and removes null instead of optional properties and undefined.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.