TypeScript类型合并

hk8txs48  于 2023-02-20  发布在  TypeScript
关注(0)|答案(2)|浏览(181)

我有一个案例,我想“合并”类型,其中默认的类型连接(即T | UT & U)没有达到我想要的。
我尝试做的是一个深度和智能的类型合并,它将在合并过程中自动将属性标记为可选,并执行TypeScript接口/类型的深度合并。
给予个例子,假设我们有类型AB

type A = {
  a: string;
  b: number;
  c: boolean;
  d: {
    a2: string;
    b2: number;
  };
  e?: number;
};

type B = {
  a: string;
  b: boolean;
  d: {
    a2: string;
    c2: boolean;
  };
};

我正在寻找一个Merge函数,它将接受2个泛型类型

type Merge<T, U> = ?????;

然后,如果在类型AB上使用,则输出如下所示

type AB = {
  a: string;
  b: number | boolean;
  c?: boolean;
  d: {
    a2: string;
    b2?: number;
    c2?: boolean;
  };
  e?: number;
};

如图所示,Merge类型将执行以下逻辑:
1.如果TU上都存在该属性,并且是相同的类型,请将其标记为必需,并在T/U中设置为该类型(与a属性类似)。
1.如果属性在TU上都存在,但类型不同,则将其标记为required,如果它是基元(类似于属性b),则将其设置为union类型,如果它是对象(类似于属性d),则执行递归合并。
1.如果属性存在于一个类型上,而不存在于另一个类型上,则将属性标记为可选,并将其设置为它实际存在的输入类型中的类型(类似于属性c以及b2c2的情况)。
1.如果属性在一个类型中已经是可选的,那么它在输出类型中也应该是可选的,并且应用上面的现有规则来确定它的值(就像e属性所发生的那样)
假设您可以使用递归条件类型,尽管我认识到它们是aren't yet officially supported,不应该在生产中使用,我可以为生产用例制作一个类似于jcalz@的解决方案here的展开版本。
这是为测试这道题而设置的操场。

w8f9ii69

w8f9ii691#

**TLDR:**魔法!试试Playground

所以,这是一个棘手的问题。与其说是因为合并要求,不如说是因为边缘情况。获得低挂水果花了〈20分钟。确保它在任何地方都工作又花了几个小时...并且长度增加了三倍。工会是棘手的!
1.什么是可选属性?在{ a: 1 | undefined, b?: 1 }a是可选属性吗?有些人说是,有些人说不是,我个人只把b放在可选列表中。
1.你如何处理联合体?Merge<{}, { a: 1} | { b: 2 }>的输出是什么?我认为最有意义的类型是{ a?: 1 } | { b?: 2 }Merge<string, { a: 1 }>呢?如果你根本不关心联合体,这很容易......如果你关心,那么你必须考虑所有这些。(我在括号中选择的)

  1. Merge<never, never>never
  2. Merge<never, { a: 1 }>{ a?: 1 }
  3. Merge<string, { a: 1 }>(x1米11英寸1x)
  4. Merge<string | { a: 1 }, { a: 2 }>string | { a: 1 | 2 }
    让我们从帮助器开始,来弄清楚这种类型。
    当我想到联合体的时候,我就有了一个暗示,这个类型会变得复杂。TypeScript没有一个很好的内置方法来测试类型是否相等,但是我们可以编写一个帮助器类型,如果两个类型不相等,它会导致编译器错误。
    (Note:Test类型可以改进,它可以允许不等价的类型传递,但它对于我们在这里的使用来说已经足够了,同时保持相当简单)
type Pass = 'pass';
type Test<T, U> = [T] extends [U]
    ? [U] extends [T]
        ? Pass
        : { actual: T; expected: U }
    : { actual: T; expected: U };

function typeAssert<T extends Pass>() {}

我们可以像这样使用这个辅助对象:

// try changing Partial to Required
typeAssert<Test<Partial<{ a: 1 }>, { a?: 1 }>>();

接下来,我们需要两个helper类型。一个用于获取对象的所有必需键,另一个用于获取可选键。首先,一些测试来描述我们的目标:

typeAssert<Test<RequiredKeys<never>, never>>();
typeAssert<Test<RequiredKeys<{}>, never>>();
typeAssert<Test<RequiredKeys<{ a: 1; b: 1 | undefined }>, 'a' | 'b'>>();

typeAssert<Test<OptionalKeys<never>, never>>();
typeAssert<Test<OptionalKeys<{}>, never>>();
typeAssert<Test<OptionalKeys<{ a?: 1; b: 1, c: undefined }>, 'a'>>();

这里有两件事需要注意。首先,*Keys<never>never。这一点很重要,因为我们稍后将在联合体中使用这些助手,如果对象是never,它不应该贡献任何键。其次,这些测试都不包括联合检查。考虑到我所说的联合体的重要性,这可能会让你感到惊讶。然而,这些类型仅在所有联合体分发后使用,因此它们的行为无关紧要(尽管如果您在项目中包含这些类型,您可能希望查看所述行为,由于其编写方式,它可能与您对RequiredKeys的预期不同)
这些类型通过给定的检查:

type OptionalKeys<T> = {
    [K in keyof T]-?: T extends Record<K, T[K]> ? never : K;
}[keyof T;

type RequiredKeys<T> = {
    [K in keyof T]-?: T extends Record<K, T[K]> ? K : never;
}[keyof T] & keyof T;

关于这些的几个注意事项:
1.使用-?删除属性的可选性,这样可以避免使用Exclude<..., undefined>的 Package 器

  1. T extends Record<K, T[K]>可以工作,因为{ a?: 1 } * 不 * 扩展{ a: 1 | undefined }。在最终解决这个问题之前,我经历了几次迭代。您还可以使用另一个Map类型(如jcalz does here)检测可选性。
    1.在版本3.8.3中,TypeScript可以正确推断OptionalKeys的返回类型可分配给keyof T。但是,它无法检测RequiredKeys的相同类型。与keyof T交叉修复了此问题。
    现在我们有了这些帮助器,我们可以定义另外两个表示业务逻辑的类型,我们需要RequiredMergeKeys<T, U>OptionalMergeKeys<T, U>
type RequiredMergeKeys<T, U> = RequiredKeys<T> & RequiredKeys<U>;

type OptionalMergeKeys<T, U> =
    | OptionalKeys<T>
    | OptionalKeys<U>
    | Exclude<RequiredKeys<T>, RequiredKeys<U>>
    | Exclude<RequiredKeys<U>, RequiredKeys<T>>;

和一些测试,以确保这些行为如预期:

typeAssert<Test<OptionalMergeKeys<never, {}>, never>>();
typeAssert<Test<OptionalMergeKeys<never, { a: 1 }>, 'a'>>();
typeAssert<Test<OptionalMergeKeys<never, { a?: 1 }>, 'a'>>();
typeAssert<Test<OptionalMergeKeys<{}, {}>, never>>();
typeAssert<Test<OptionalMergeKeys<{ a: 1 }, { b: 2 }>, 'a' | 'b'>>();
typeAssert<Test<OptionalMergeKeys<{}, { a?: 1 }>, 'a'>>();

typeAssert<Test<RequiredMergeKeys<never, never>, never>>();
typeAssert<Test<RequiredMergeKeys<never, {}>, never>>();
typeAssert<Test<RequiredMergeKeys<never, { a: 1 }>, never>>();
typeAssert<Test<RequiredMergeKeys<{ a: 0 }, { a: 1 }>, 'a'>>();

现在我们有了这些,我们可以定义两个对象的合并,暂时忽略原语和联合,这将调用我们还没有定义的顶级Merge类型来处理成员的原语和联合。

type MergeNonUnionObjects<T, U> = {
    [K in RequiredMergeKeys<T, U>]: Merge<T[K], U[K]>;
} & {
    [K in OptionalMergeKeys<T, U>]?: K extends keyof T
        ? K extends keyof U
            ? Merge<Exclude<T[K], undefined>, Exclude<U[K], undefined>>
            : T[K]
        : K extends keyof U
        ? U[K]
        : never;
};

(我没有在这里编写特定的测试,因为我在下一个级别有它们)
我们需要处理联合体和非对象。接下来让我们处理对象的联合体。根据前面的讨论,我们需要分布在所有类型上并单独合并它们。这非常简单。

type MergeObjects<T, U> = [T] extends [never]
    ? U extends any
        ? MergeNonUnionObjects<T, U>
        : never
    : [U] extends [never]
    ? T extends any
        ? MergeNonUnionObjects<T, U>
        : never
    : T extends any
    ? U extends any
        ? MergeNonUnionObjects<T, U>
        : never
    : never;

注意我们对[T] extends [never][U] extends [never]有额外的检查,这是因为在一个分配子句中的never就像for (let i = 0; i < 0; i++),它永远不会进入条件的“body”,因此会返回never,但是我们只需要never,如果 * 两个 * 类型都是never
我们快到了!现在我们可以处理合并对象了,这是这个问题最难的部分。剩下的就是处理原语,我们可以通过形成所有可能原语的并集,并排除传递给MergeObjects的类型的原语来完成。

type Primitive = string | number | boolean | bigint | symbol | null | undefined;

type Merge<T, U> =
    | Extract<T | U, Primitive>
    | MergeObjects<Exclude<T, Primitive>, Exclude<U, Primitive>>;

有了这个类型,我们就大功告成了!Merge的行为符合预期,只有大约50行未注解的疯狂代码。
@petroni在评论中提到,这种类型不能很好地处理同时存在于两个对象中的数组。有几种不同的方法可以处理这种情况,特别是因为TypeScript的元组类型已经变得越来越灵活。正确地合并[1, 2][3]可能会给予[1 | 3, 2?]...但这样做至少和我们的一个简单得多的解决方案是完全忽略元组,并且总是生成一个数组,所以这个例子将生成(1 | 2 | 3)[]
关于生成类型的最后一点说明:

Merge的结果类型现在是正确的,但是它不是那么可读。现在将鼠标悬停在结果类型上将显示一个交集和内部对象,而不是显示结果。我们可以通过引入Expand类型来修复这个问题,该类型强制TS将所有内容扩展为单个对象。

type Expand<T> = T extends Primitive ? T : { [K in keyof T]: T[K] };

现在只需修改MergeNonUnionObjects以调用Expand。在必要的情况下,这是一种尝试和错误。您可以尝试包含它或不包含它,以获得适合您的类型显示。

type MergeNonUnionObjects<T, U> = Expand<
    {
        [K in RequiredMergeKeys<T, U>]: Expand<Merge<T[K], U[K]>>;
    } & {
        [K in OptionalMergeKeys<T, U>]?: K extends keyof T
            ? K extends keyof U
                ? Expand<Merge<
                    Exclude<T[K], undefined>,
                    Exclude<U[K], undefined>
                >>
                : T[K]
            : K extends keyof U
            ? U[K]
            : never;
    }
>;

在操场上看看吧,里面包括了我用来验证结果的所有测试。

6mw9ycah

6mw9ycah2#

我尝试使用type-fest库中的MergeDeep类型和ramda的mergeDeepWith方法来解决这个问题。
demo code

import { mergeDeepWith } from "ramda";
import type { MergeDeep, MergeDeepOptions } from "type-fest";

type Foo = {
  life: number;
  items: string[];
  users: { id: number; name: string }[];
  a: { b: string; c: boolean; d: number[] };
};

type Bar = {
  name: string;
  items: number[];
  users: { id: number; name: string }[];
  a: { b: number; d: boolean[] };
};

type FooBar = MergeDeep<Foo, Bar>;

const mergeDeep = <Source, Destination, Options extends MergeDeepOptions = {}>(
  source: Source,
  destination: Destination,
  options?: Options
): MergeDeep<Source, Destination, Options> => {
  // https://github.com/sindresorhus/type-fest/blob/main/source/merge-deep.d.ts#L416-L456
  // Make your implementation ...
  const mergedObj = mergeDeepWith<Source, Destination>(
    (x, y) => {
      // https://github.com/denoland/deno/blob/main/ext/node/polyfills/util.ts#L30-L32
      if (Array.isArray(x) && Array.isArray(y)) {
        return [...x, ...y];
      }
      if (x) return x;
      if (y) return y;
      return null;
    },
    source,
    destination
  );
  return mergedObj;
};

const a: Foo = {
  life: 1,
  items: ["a"],
  users: [
    {
      id: 1,
      name: "user1",
    },
    {
      id: 2,
      name: "user2",
    },
  ],
  a: {
    b: "@",
    c: false,
    d: [1, 2, 3],
  },
};

const b: Bar = {
  name: "bar",
  items: [4, 5, 6],
  users: [
    {
      id: 3,
      name: "user3",
    },
    {
      id: 4,
      name: "user4",
    },
  ],
  a: {
    b: 111,
    d: [true, false],
  },
};

const result = mergeDeep<Foo, Bar>(a, b);

console.log(result);

相关问题