网站搜索

javascript - 映射类型,其中一个键是可能键中的必需键


下面的代码允许创建一个对象,该对象包含“k1”或“k2”或(“k1”和“k2”)或都不包含。

type Foo = "k1"|"k2";

type Bar = {[key in Foo]?: string};

const myObj1: Bar = { // ok

}

const myObj3: Bar = { // ok
    "k1": "random string",
    "k2": "random string",
}

我的目标是能够拥有一个且仅有一个必需的键,要么是“k1”,要么是“k2”。

所以我想能够做到以下几点:

const myObj1: Bar = { // Compile error

}

const myObj2: Bar = { // Compile error
    "k1": "random string",
    "k2": "random string",
}

const myObj3: Bar = { // ok
    "k1": "random string",
}

const myObj4: Bar = { // ok
    "k2": "random string",
}

答案#1

为了使其按你希望的方式工作,你需要 Bar 是一个联合类型,其中联合中的每个成员都有一个必需属性,其键来自 Foo,值的类型为 string,而 Foo 中的其余键是被禁止的。 TypeScript 并不真正允许你禁止一个键(至少不启用 --exactOptionalPropertyTypes 编译选项时),但将它们设为 never 类型的可选属性非常接近:例如,{x?: never} 意味着 x 属性要么不存在,要么必须存在且值的类型为 never(这是不可能的)或 undefined(可选属性会自动加上)。无论如何,这意味着对于你问题中的特定 Foo,你希望 Bar 是:

type Bar = {
    k1: string;
    k2?: never;
} | {
    k2: string;
    k1?: never;
}

但可以假设你想要从 Foo 计算 Bar,这样如果 Foo 发生变化,Bar 会自动更新以匹配它。这里有一种方法可以做到这一点:

type Bar = { [K in Foo]:
  { [P in K]: string } & { [P in Exclude<Foo, K>]?: never }
}[Foo];

这是使用一个映射类型,并立即对其进行索引以获得一个联合类型。我们可以称之为“分发类型”。任何类似于 {[K in XXX]: YYY<K>}[XXX] 的方法,其中 XXX 是某个联合类型 K1 | K2 | K3,都会创建一个形如 YYY<K1> | YYY<K2> | YYY<K3> 的分发类型。

在上述类型中,我们因此正在为 Foo 中的每个 K 计算 { [P in K]: string } & { [P in Exclude<Foo, K>]?: never } 的并集。所以它将变为 ({ [P in "k1"]: string } & { [P in Exclude<Foo, "k1">]?: never }) | ({ [P in "k2"]: string } & { [P in Exclude<Foo, "k2">]?: never })

让我们只看那部分的开头:

{ [P in "k1"]: string } & { [P in Exclude<Foo, "k1">]?: never }

左半部分等价于 {k1: string}(因为我们只映射一个键),右半部分等价于 {k2?: never},因为 Exclude<T, U> 工具类型会过滤掉联合成员。这两部分相交在一起,所以 {k1: string} & {k2?: never},这等价于 {k1: string, k2?: never}(但不会以这种方式表示)。

所以上述将生成一种类型

/* type Bar = ({
    k1: string;
} & {
    k2?: never;
}) | ({
    k2: string;
} & {
    k1?: never;
}) */

它的作用和你想的一样,但有那么多交叉,看起来有点丑。

你可以通过执行一个“无操作”的映射类型,让编译器将对象类型的交集折叠为单一的对象类型。如果 O 是 {a: 1} & {b: 2},那么 {[P in keyof O]: O[P]} 将会是 {a: 1, b: 2}。所以我们可以扩展 Bar 的定义来实现这一点:

type Bar = { [K in Foo]:
  { [P in K]: string } & { [P in Exclude<Foo, K>]?: never } extends
  infer O ? { [P in keyof O]: O[P] } : never
}[Foo];

这段代码所做的是将 { [P in K]: string } & { [P in Exclude<Foo, K>]?: never } 复制到一个新的类型参数 O,然后使用 {[P in keyof O]: O[P]} 遍历它以合并交叉类型。哦,我还使用了条件类型推断来完成复制。任何形式为 XXX extends infer O ? YYY<O> : never 的类型都等价于 YYY<XXX>。

所以最后,根据这个定义,我们得到:

/* type Bar = {
    k1: string;
    k2?: never;
} | {
    k2: string;
    k1?: never;
} */

让我们确保它按照你的想法工作:

const myObj1: Bar = { // Compile error
}

const myObj2: Bar = { // Compile error
  "k1": "random string",
  "k2": "random string",
}

const myObj3: Bar = { // ok
  "k1": "random string",
}

const myObj4: Bar = { // ok
  "k2": "random string",
}

并且确保当你向 Foo 添加一个键时它会更新:

type Foo = "k1" | "k2" | "k3";        

/* type Bar = {
  k1: string;
  k2?: never;
  k3?: never;
} | {
  k2: string;
  k1?: never;
  k3?: never;
} | {
  k3: string;
  k1?: never;
  k2?: never;
} */

看起来不错!

代码的 Playground 链接

答案#2

试试这个:

type Bar = {k1: string, k2?: never} | {k1?: never, k2: string};