如何使用联合类型深度扁平化 Typescript 接口并在键中保留完整的对象路径

DaD*_*aDo 6 typescript

假设我有以下界面:

interface Foo {
  foo: string
  bar?: number
  nested: {
    foo: string,
    deeplyNested: {
      bar?: number
    }
  }
  union: string | {
    foo: string,
    bar?: number
  }
}
Run Code Online (Sandbox Code Playgroud)

现在我需要一种类型来将此接口转换为扁平版本,键中包含完整路径名,如下所示:

interface FooFlattened {
  foo: string
  bar?: number
  "nested.foo": string
  "nested.deeplyNested.bar"?: number

  // if union is a string:
  union: string

  // if union is an object:
  "union.foo": string
  "union.bar"?: number
}
Run Code Online (Sandbox Code Playgroud)

该类型应该适用于任何级别的嵌套和任何数量的联合成员。

我已经在这里找到了一个相关的问题,但这只是一个非常基本的界面,没有联合或可选参数。

jca*_*alz 8

有很多方法可以解决这个问题,根据我的经验,它们都很棘手、繁琐、脆弱,并且有很多疯狂的边缘情况。请参阅如何展平具有嵌套子属性的对象类型?对于类似的问题和类似的充满警告的答案。

最后,针对这种情况我决定采取以下方法。Entry我们可以采用一个对象类型并将其表示为元素的联合,其中 anEntry具有keyvalueoptional属性:

type Entry = { key: string, value: any, optional: boolean };
Run Code Online (Sandbox Code Playgroud)

所以对于像你这样的类型,{a: string, b?: number}你会得到{key: "a", value: string, optional: false} | {key: "b", value: number | undefined, optional: true}.

第一步,我们将像FooExplode这样的输入接口放入一个大的元素联合中Entry,其中深度嵌套的属性将转换为带有点路径的单个键。那么Explode<{a: {b: string}}>就会变成{key: "a.b", value: string, optional: false}。这完成了算法的繁重工作,我们必须决定诸如可选属性如何传播、联合如何传播等问题。

的可能定义Explode如下:

type Explode<T> =
    T extends object ? { [K in keyof T]-?:
        K extends string ? Explode<T[K]> extends infer E ? E extends Entry ?
        {
            key: `${K}${E['key'] extends "" ? "" : "."}${E['key']}`,
            value: E['value'],
            optional: E['key'] extends "" ? {} extends Pick<T, K> ? true : false : E['optional']
        }
        : never : never : never
    }[keyof T] : { key: "", value: T, optional: false }
Run Code Online (Sandbox Code Playgroud)

如果我们Explode是非对象类型,我们使用空白键;否则,我们递归地获取Explode对象的所有属性,然后使用模板文字类型连接键,并决定具有可选属性的内容(我认为我这样做是为了{a: {b?: string}}使可选属性成为b可选属性,但又{a?: {b: string}}使属性成为b必需属性)。一团糟。

哦,我不知道如何处理数组;我决定只使用"0"作为表示数组索引的键,因此我将其重命名Explode<T>_Explode<T>,然后Explode<T>根据它进行定义:

type Explode<T> = _Explode<T extends readonly any[] ? { "0": T[number] } : T>;
Run Code Online (Sandbox Code Playgroud)

最后,一旦我们有了元素的完整分解联合Entry,我们就可以将Collapse它们组合成一个对象类型:

type Collapse<T extends Entry> = (
    { [E in Extract<T, { optional: false }> as E['key']]: E['value'] }
    & Partial<{ [E in Extract<T, { optional: true }> as E['key']]: E['value'] }>
) extends infer O ? { [K in keyof O]: O[K] } : never
Run Code Online (Sandbox Code Playgroud)

我使用键重新映射 viaas来迭代对象E联合的T每个元素Entry;每个键都是E['key'],每个值都是E['value']。通过将联合分为具有属性truefalse值的联合optional,我们可以分别生成具有可选属性和必需属性的输出类型。

最后,Flatten<T>这就是当你Explode进入Entry对象然后进入Collapse这些对象时你要做的事情:

type Flatten<T> = Collapse<Explode<T>>
Run Code Online (Sandbox Code Playgroud)

以下是它在您的示例中的工作原理:

type FooFlattened = Flatten<Foo>
/* type FooFlattened = {
  foo: string;
  "nested.foo": string;
  union: string;
  "union.foo": string;
  bar?: number | undefined;
  "nested.deeplyNested.bar"?: number | undefined;
  "union.bar"?: number | undefined;
}*/
Run Code Online (Sandbox Code Playgroud)

它与您手动编写的预期输出完全相同(属性顺序除外,这不会影响类型相等性)。


对于更复杂的事情,比如

interface Foo {
    tmdb: number | {
        title: {
            original: string
            german?: string
        }
        budget?: number
        revenue?: number
        tagline?: string
        overview?: string
        productionCompanies?: {
            id?: number
            logoPath?: string
            name?: string
            originCountry?: string
        }[]
        releaseDate?: string
        genres?: string[]
        runtime?: number
        poster?: string | {
            data: { sample: any } 
            contentType: string
        }
    }
    rating: { ch: number; rt: number } | { total: number }
    dateSeen?: Date
    fsk?: number
    mm?: boolean
}
Run Code Online (Sandbox Code Playgroud)

我们得到

type FlattenedFoo = Flatten<Foo>
/* type FlattenedFoo = {
    tmdb: number;
    "tmdb.title.original": string;
    "tmdb.genres.0": string;
    "tmdb.poster.data.sample": any;
    "tmdb.poster.contentType": string;
    "rating.ch": number;
    "rating.rt": number;
    "rating.total": number;
    "tmdb.title.german"?: string | undefined;
    "tmdb.budget"?: number | undefined;
    "tmdb.revenue"?: number | undefined;
    "tmdb.tagline"?: string | undefined;
    "tmdb.overview"?: string | undefined;
    "tmdb.productionCompanies"?: undefined;
    "tmdb.productionCompanies.0.id"?: number | undefined;
    "tmdb.productionCompanies.0.logoPath"?: string | undefined;
    "tmdb.productionCompanies.0.name"?: string | undefined;
    "tmdb.productionCompanies.0.originCountry"?: string | undefined;
    "tmdb.releaseDate"?: string | undefined;
    "tmdb.genres"?: undefined;
    "tmdb.runtime"?: number | undefined;
    "tmdb.poster"?: string | undefined;
    dateSeen?: undefined;
    fsk?: number | undefined;
    mm?: boolean | undefined;
} */
Run Code Online (Sandbox Code Playgroud)

我认为这是合理的。


当然,在某些极端情况下,这可能会导致您不想要的结果。它们可以通过改变Explode工作方式来解决,或者您所做的任何事情都无法满足您所有预期的用例,您将不得不决定要么放弃一些用例,要么放弃Flatten.

Playground 代码链接