如何使用字符串联合填充对象类型的可选嵌套关系?

How can I populate an object type's optional nested relations using string unions?

我正在尝试创建一个采用 2 个泛型的 Populate 类型:具有可选关系的对象类型(引用其他对象类型的键),以及可以深度填充的路径字符串联合(或者更确切地说,设置为非可选)的关系。例如:有 3 个实体都可以选择相互引用:

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
} 
*/

这样做的目的是让我能够在我的一些查询中使用 TypeORM 的 relations 键键入结果对象,这可以填充相互引用的关系对象。不幸的是,TypeORM 的 return 类型始终是基本实体类型,并且无论您将哪些关系传递到查询中,任何关系始终保持可选。我想转换 return 类型以使关系在被查询时不是可选的。例如:

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
*/
    

让我们首先定义两个将要使用的实用程序类型:

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;

这些类型简单地拆分和连接路径(彼此相反)。

  • SplitPath<"country.tags.companies"> 会给我一个元组 ["country", "tags", "companies"]

  • JoinPath<["country", "tags", "companies"]> 会给我一个字符串 "country.tags.companies"

然后一个类型递归扩展给定类型以进行调试:

// 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;

没有它,当我们尝试查看 Populate 的结果时,它不会显示结果,而是实际显示 Populate<Company, [...]>,这没有帮助。使用这种类型,它将 Populate<...> 扩展为我们可以看到的完整形式。

现在我们定义一个简单的find函数来测试Populate:

declare function find<Relations extends ReadonlyArray<string> = never>(criteria: {
    id?: number;
    relations?: Relations;
    // ...
}): Expand<Populate<Company, Relations>>;

因为 relations 是可选的,所以我为 Relations 通用参数添加了默认值 never。然后当你不提供关系时 Populate 没有效果。

填充 Company 后,我们将其展开为完整形式。

这里是 Populate,乍一看似乎令人生畏:

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>>>
};

首先,我们忽略 T 中所有受影响的属性,因为我们稍后会添加受影响的属性:

Omit<T, SplitPath<Keys[number]>[0]>

让我们使用一个简单的示例 Populate<Company, ["country", "country.tags"]> 来了解 Populate 的工作原理并简化此 Omit 用法:

  • Omit<Company, SplitPath<["country", "country.tags"][number]>[0]>
  • Omit<Company, SplitPath<"country" | "country.tags">[0]>
  • Omit<Company, (["country"] | ["country", "tags"])[0]>
  • Omit<Company, "country">

在我们省略所有受影响的 first-level 键之后,我们将其与映射类型相交以将它们添加回来:

{
    [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>>>
}

再一次,为了理解我们在这里做什么,让我们简化一下 step-by-step。所以首先像上面一样,我们得到受影响的 first-level 键并映射它们:

{
    [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>>>
}

-? 要求 属性。没有它,它仍然是可选的。

所以现在,映射类型简化为:

{
    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">>>
}

现在,"country" & keyof T可能看起来重复和无用,但实际上没有& keyof T它会抛出K不能用于索引类型T的错误。您可以将 & keyof T 视为 KT 的键的断言。所以这进一步简化为:

{
    country: NonNullable<T["country"]> extends ReadonlyArray<unknown>
            ? Populate<NonNullable<T["country"]>[number], OmitFirstLevel<Keys, "country">>[]
            : NonNullable<Populate<NonNullable<T["country"]>, OmitFirstLevel<Keys, "country">>>
}

现在看起来还不错,但是到处都是 NonNullable 是怎么回事?因为 T["country"] 是可选的,所以它可以是未定义的。我们不希望在操作它时未定义,所以我们用 NonNullable 排除它。另一种选择是 Exclude<T["country"], undefined>,但 NonNullable 更短更明确。

为简洁起见,让我们在此处删除 NonNullable,以便我们了解实际情况:

{
    country: T["country"] extends ReadonlyArray<unknown>
            ? Populate<T["country"][number], OmitFirstLevel<Keys, "country">>[]
            : Populate<T["country"], OmitFirstLevel<Keys, "country">>>
}

好的,现在很容易看出发生了什么。 我们检查 T["country"] 是否是一个数组,如果是,我们填充数组元素的类型并将其放回数组中,否则,我们只填充 T["country"].

但是用什么键?这就是 OmitFirstLevel 所做的。直到现在我都把它藏起来了,因为它很乱,可以稍微清理一下,但现在是:

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
;

用一些例子可能更容易解释:

  • OmitFirstLevel<["country", "country.tags"], "country"> 给出 ["tags"]
  • OmitFirstLevel<["country", "country.tags", "country.tags.companies"], "country"> 给出 ["tags", "tags.companies"]
  • OmitFirstLevel<["country", "country.tags", "tags", "tags.companies"], "country"> 给出 ["tags"]

您可能会看到它只是获取所有以给定值开头的路径,然后从路径中删除给定值。

现在,进一步简化我们的映射类型:

{
    country: T["country"] extends ReadonlyArray<unknown>
            ? Populate<T["country"][number], ["tags"]>>[]
            : Populate<T["country"], ["tags"]>>
}

因为 T["country"] 实际上不是一个数组,所以它简化为:

{
    country: Populate<T["country"], ["tags"]>
}

哦,看!它为我们填充了 T["country"]tags 属性!那很好!这就是整盘意大利面的作用。如果您想了解有关 JoinPathSplitPathOmitFirstLevel 工作原理的更多详细信息,请提及我,我会修改此 post 以包含一些内容。

Playground