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

2023-12-06

我正在尝试创建一个Populate需要 2 个泛型的类型:具有可选关系的对象类型(引用其他对象类型的键),以及可以深度填充(或者更确切地说,设置为非可选)关系的 Path 字符串的联合。例如:有 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 的返回类型始终是基本实体类型,并且无论您将哪种关系传递到查询中,任何关系始终保持可选。我想转换返回类型以使关系在被查询时不可选。例如:

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

Because relations是可选的我添加了默认值never为了Relations通用参数。然后当你不提供关系时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 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">

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

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

再次强调,为了理解我们在这里所做的事情,让我们逐步简化这一过程。因此,首先像上面一样,我们获取受影响的一级键并映射它们:

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

Now, "country" & keyof T看似重复且无用,但实际上没有& keyof T它会抛出错误K不能用于索引类型T。你可以想到& keyof T作为一个断言K是一个关键T。所以这进一步简化为:

{
    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> but 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"> gives ["tags"]
  • OmitFirstLevel<["country", "country.tags", "country.tags.companies"], "country"> gives ["tags", "tags.companies"]
  • OmitFirstLevel<["country", "country.tags", "tags", "tags.companies"], "country"> gives ["tags"]

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

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

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

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

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

哦,看!它填充T["country"]'s tags属于我们的财产!那很好!这就是整盘意大利面的作用。如果您想了解更多详细信息JoinPath, SplitPath, or OmitFirstLevel有效,提及我,我将修改这篇文章以包含一些内容。

操场

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

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

随机推荐