映射对象类型但属性类型不一样

Mapping object type but properties types are not the same

我想将一种结构类型转换为另一种结构类型。源结构是一个对象,它可能包含由点分隔的属性键。我想将那些“逻辑组”扩展为子对象。

由此可见:

interface MyInterface {
    'logicGroup.timeout'?: number;
    'logicGroup.serverstring'?: string;
    'logicGroup.timeout2'?: number;
    'logicGroup.networkIdentifier'?: number;
    'logicGroup.clientProfile'?: string;
    'logicGroup.testMode'?: boolean;
    station?: string;
    other?: {
        "otherLG.a1": string;
        "otherLG.a2": number;
        "otherLG.a3": boolean;
        isAvailable: boolean;
    };
}

对此:

interface ExpandedInterface {
    logicGroup: {
        timeout?: number;
        serverstring?: string;
        timeout2?: number;
        networkIdentifier?: number;
        clientProfile?: string;
        testMode?: boolean;
    }
    station?: string;
    other?: {
        otherLG: {
            a1: string;
            a2: number;
            a3: boolean;
        },
        isAvailable: boolean;
    };
}

__

(EDIT) 当然,上面的这两个结构是基于一个真实的 Firebase-Remote-Config Typescript 转置,它不能有任意属性(没有索引签名)或键碰撞(我们不希望有任何 logicGrouplogicGroup.anotherProperty)。

属性可以是可选的 (logicGroup.timeout?: number),我们可以假设如果 logicGroup 中的所有属性都是可选的,那么 logicGroup 本身可以是可选的。

我们确实希望可选的 属性 (logicGroup.timeout?: number) 将保持相同的类型 (logicGroup: { timeout?: number }) 并且不会成为强制性的,并有可能明确接受 undefined 作为值 (logicGroup: { timeout: number | undefined }).

我们希望所有属性都是对象、字符串、数字、布尔值。没有数组,没有并集,没有交集。


我尝试了一些映射类型、键重命名、条件类型等实验。我想出了这个部分解决方案:

type UnwrapNested<T> = T extends object
    ? {
        [
            K in keyof T as K extends `${infer P}.${string}` ? P : K
        ]: K extends `${string}.${infer C}` ? UnwrapNested<Record<C, T[K]>> : UnwrapNested<T[K]>;
      }
    : T;

哪个输出我想要的:

type X = UnwrapNested<MyInterface>;
//   ^?    ===> { logicGroup?: { serverString: string | undefined } | { testMode: boolean | undefined } ... }

所以有两个问题:

  1. logicGroup 是分布式联合体
  2. logicGroup 属性持有 | undefined 而不是实际可选的。

所以我试图阻止按衣服分发K值:

type UnwrapNested<T> = T extends object
    ? {
        [
            K in keyof T as K extends `${infer P}.${string}` ? P : K
        ]: [K] extends [`${string}.${infer C}`] ? UnwrapNested<Record<C, T[K]>> : UnwrapNested<T[K]>;
      }
    : T;

这是输出,但仍然不是我想要得到的:

type X = UnwrapNested<MyInterface>;
//  ^?    ===> { logicGroup?: { serverString: string | boolean | number | undefined, ... }}

一个问题消失了,另一个问题出现了。所以问题是:

  1. 所有子属性都作为一个类型获得同一“逻辑组”中所有可用值的联合
  2. 所有子属性都包含 | undefined 而不是实际可选的。

我也尝试过使用其他选项来过滤类型等,但我实际上并不知道我错过了什么。

我还找到了 ,它实际上可以独立运行,但我无法将它整合到我的公式中。

我不明白什么? 非常感谢!

在 type-fest 的 UnionToIntersection:

的帮助下,我设法让它工作
import type { UnionToIntersection } from 'type-fest';

// Helper type for optional/undefined properties
type PartialOnUndefined<T extends object> = {
    [Key in {
        [K in keyof T]: undefined extends T[K] ? never : K
    }[keyof T]]: T[Key]
} & Partial<T>;

// Not strictly necessary, but makes the result more legible
type Expand<T> = T extends ReadonlyArray<unknown>
    ? number extends T["length"]
        ? Expand<T[number]>[]
        : { [K in keyof T]: Expand<T[K]> }
    : T extends object
        ? { [K in keyof T]: Expand<T[K]> }
        : T;

export type UnwrapNested<T> = Expand<{
    [
        K in keyof T as K extends `${infer P}.${string}` ? P : K
    ]: UnionToIntersection<
        K extends `${string}.${infer C}` ? UnwrapNested<Record<C, T[K]>> : T[K]
    > extends never
        ? UnwrapNested<T[K]>
        : UnionToIntersection<
            K extends `${string}.${infer C}`
                ? PartialOnUndefined<UnwrapNested<Record<C, T[K]>>>
                : UnwrapNested<T[K]>
        >;
    }>;

Playground link for an example.

非常清楚,我不明白 UnwrapNested 中的大量三元组如何或为何起作用。

这种深度对象类型处理总是充满边缘情况。我将为 Expand<T> 提出一种可能的方法,它可以满足问题提问者的需求,但是遇到这个问题的任何其他人都应该仔细测试针对他们的用例显示的任何解决方案。

所有此类答案肯定会使用 recursive conditional types,其中 Expand<T> 是根据对象定义的,其属性本身是根据 Expand<T>.

编写的

为清楚起见,在继续 Expand<T> 本身之前,我会将定义拆分为一些辅助类型。


首先,我们要根据第一个点字符 (".") 在字符串中的位置和存在情况来过滤和转换 unions of string literal types

type BeforeDot<T extends PropertyKey> =
  T extends `${infer F}.${string}` ? F : never;
type AfterDot<T extends PropertyKey> =
  T extends `${string}.${infer R}` ? R : never;
type NoDot<T extends PropertyKey> =
  T extends `${string}.${string}` ? never : T;

type TestKeys = "abc.def" | "ghi.jkl" | "mno" | "pqr" | "stu.vwx.yz";
type TKBD = BeforeDot<TestKeys> // "abc" | "ghi" | "stu"
type TKAD = AfterDot<TestKeys> //  "def" | "jkl" | "vwx.yz"
type TKND = NoDot<TestKeys> // "mno" | "pqr"

这些都是在template literal type中使用推理。 BeforeDot<T>T 过滤为仅包含点的字符串,并计算第一个点之前的那些字符串的部分。 AfterDot<T> 类似,但它计算第一个点之后的部分。并且 NoDot<T>T 过滤为那些字符串 而没有 一个点。


并且我们希望将多个对象类型合并为一个对象类型;这基本上是一个 intersection,但是我们迭代交集的属性以将它们连接在一起:

type Merge<T, U> =
  (T & U) extends infer O ? { [K in keyof O]: O[K] } : never;

type TestMerge = Merge<{ a: 0, b?: 1 }, { c: 2, d?: 3 }>
// type TestMerge = { a: 0; b?: 1; c: 2; d?: 3 }

这主要是为了美观;我们可以只使用交集,但结果类型显示得不是很好。


现在是 Expand<T>:

type Expand<T> = T extends object ? (
  Merge<
    {
      [K in keyof T as BeforeDot<K>]-?: Expand<
        { [P in keyof Pick<T, K> as AfterDot<P>]: T[P] }
      >
    }, {
      [K in keyof T as NoDot<K>]: Expand<T[K]>
    }
  >
) : T;

让我们分块检查这个定义...首先: T extends object ? (...) : T; 如果 T 不是某种对象类型,我们根本不转换它。例如,我们希望 Expand<string> 只是 string。所以现在我们需要看看当 T 是一个对象类型时会发生什么。

我们会将此对象类型分为两部分:键包含点的属性和键不包含点的属性。对于没有点的键,我们只想将 Expand 应用于所有属性。那是 {[K in keyof T as NoDot<K>]: Expand<T[K]>} 部分。请注意,我们使用 key remapping via as to filter and transform the keys. Because we are iterating over K in keyof T, and not applying any mapping modifiers, this is a 保留输入属性的修饰符。因此,对于键中没有点的任何 属性,输出 属性 将是可选的,前提是输入 属性 是可选的。与 readonly.

相同

对于属性键包含点的那一块,我们需要做一些更复杂的事情。首先,我们需要将键转换为第一个点之前的部分。因此 [K in keyof T as BeforeDot<K>]。请注意,如果两个键 K 具有相同的初始部分,如 "foo.bar""foo.baz",它们都将折叠为 "foo",然后类型 K正如 属性 所见,值将是并集 "foo.bar" | "foo.baz"。所以我们需要在 属性 值中处理这样的联合键。

接下来,我们希望输出属性根本不是可选的。如果带有 "foo.bar" 之类键的 属性 是可选的,我们希望只有最深的 属性 是可选的。类似于 {foo: {bar?: any}} 而不是 {foo?: {bar: any}}{foo?: {bar?: any}}。至少这与问题中提出的 ExpandedInterface 是一致的。因此我们使用 -? 映射修饰符。这会处理密钥,并给我们 {[K in keyof T as BeforeDot<K>]-?: ...}.

至于带点键的映射属性的,我们需要在向下递归Expand之前对其进行转换。我们需要将键 K 的并集替换为第一个点 之后的 部分,而不更改 属性 值类型。所以我们需要另一个映射类型。我们可以只写 {[P in K as AfterDot<P>]: T[P]},但这样映射在 T 中就不是同态的,我们会丢失任何可选属性。为确保此类可选属性向下传播,我们需要使其同态并具有 {[P in keyof XXXX]: ...} 形式,其中 XXXX 仅具有 K 中的键,但具有与 T 相同的修饰符.嘿,我们可以为此使用 the Pick<T, K> utility type。所以{[P in keyof Pick<T, K> as AfterDot<P>]: T[P]}。而我们 Expand 那,所以 Expand<{[P in keyof Pick<T, K> as AfterDot<P>]: T[P]}>.

好的,现在我们有了键中没有点的部分和键中有点的部分,我们需要将它们重新组合在一起。这就是整个 Expand<T> 定义:

type Expand<T> = T extends object ? (
  Merge<
    {
      [K in keyof T as BeforeDot<K>]-?: Expand<
        { [P in keyof Pick<T, K> as AfterDot<P>]: T[P] }
      >
    }, {
      [K in keyof T as NoDot<K>]: Expand<T[K]>
    }
  >
) : T;

好的,让我们在您的 MyInterface:

上测试一下
type ExpandMyInterface = Expand<MyInterface>;
/* type ExpandMyInterface = {
    logicGroup: {
        timeout?: number | undefined;
        serverstring?: string | undefined;
        timeout2?: number | undefined;
        networkIdentifier?: number | undefined;
        clientProfile?: string | undefined;
        testMode?: boolean | undefined;
    };
    station?: string | undefined;
    other?: {
        otherLG: {
            a1: string;
            a2: number;
            a3: boolean;
        };
        isAvailable: boolean;
    } | undefined;
} */

这看起来是一样的,但让我们确保编译器是这样认为的,通过名为 MutuallyExtends<T, U> 的辅助类型,它只接受相互扩展的 TU

type MutuallyExtends<T extends U, U extends V, V = T> = void;

type TestExpandedInterface = MutuallyExtends<ExpandedInterface, ExpandMyInterface> // okay

编译没有错误,所以编译器认为ExpandMyInterfaceExpandedInterface本质上是一样的。万岁!

Playground link to code