如何在TypeScript 3.0中强制接口“实现”枚举的键?

5
假设我有一个枚举E { A = "a", B = "b"}。我想要强制一些接口或类型(为了可读性,我将只提到接口)拥有E的所有键。但是,我想要单独指定每个字段的类型。因此,{ [P in E]: any }{ [P in E]: T }并不是合适的解决方案。
例如,代码可能包含实现E的两个接口:
  • E { A = "a", B = "b"}
  • Interface ISomething { a: string, b: number}
  • Interface ISomethingElse { a: boolean, b: string}
随着开发过程中E的扩展,它可能变成:
  • E { A = "a", B = "b", C="c"}
  • Interface ISomething { a: string, b: number, c: OtherType}
  • Interface ISomethingElse { a: boolean, b: string, c: DiffferntType}
几个小时后:
  • E { A = "a", C="c", D="d"}
  • Interface ISomething { a: string, c: ChosenType, d: CarefullyChosenType}
  • Interface ISomethingElse { a: boolean, c: DiffferntType, d: VeryDifferentType}
因此,从 https://www.typescriptlang.org/docs/handbook/advanced-types.html 看来,目前还没有支持。我是否遗漏了任何 TypeScript 技巧?
7个回答

14

我猜你想要写出enuminterface,希望TypeScript会警告你interface中缺少了enum的键(或者如果它有额外的键)?

假设你有以下代码:

enum E { A = "a", B = "b", C="c"};
interface ISomething { a: string, b: number, c: OtherType};

你可以使用条件类型来让 TypeScript 判断 E 中是否有任何成员缺失于 ISomething 的键中:
type KeysMissingFromISomething = Exclude<E, keyof ISomething>;

如果你的 ISomething 中没有任何缺失的键,那么这种类型应该是 never。否则,它将成为 E 的值之一,例如 E.C

您还可以使用条件类型让编译器找出 ISomething 是否有任何不属于 E 的键...尽管这更复杂,因为无法以预期的方式对枚举进行编程操作。代码如下:

type ExtraKeysInISomething = { 
  [K in keyof ISomething]: Extract<E, K> extends never ? K : never 
}[keyof ISomething];

如果你没有额外的键,这将永远不会发生。然后,你可以使用泛型约束默认类型参数来强制在这两种情况下都是never时出现编译时错误:

type VerifyISomething<
  Missing extends never = KeysMissingFromISomething, 
  Extra extends never = ExtraKeysInISomething
> = 0;

VerifyISomething 这个类型本身并不重要(它始终是 0),但是通用参数 MissingExtra,如果它们各自的默认值不是 never,将会给您带来错误。

让我们试一试:

enum E { A = "a", B = "b", C = "c" }
interface ISomething { a: string, b: number, c: OtherType }
type VerifyISomething<
  Missing extends never = KeysMissingFromISomething,
  Extra extends never = ExtraKeysInISomething
  > = 0; // no error

and
enum E { A = "a", B = "b", C = "c" }
interface ISomething { a: string, b: number } // oops, missing c
type VerifyISomething<
  Missing extends never = KeysMissingFromISomething, // error!
  Extra extends never = ExtraKeysInISomething
  > = 0; // E.C does not satisfy the constraint

enum E { A = "a", B = "b", C = "c" }
interface ISomething { a: string, b: number, c: OtherType, d: 1} // oops, extra d
type VerifyISomething<
  Missing extends never = KeysMissingFromISomething,
  Extra extends never = ExtraKeysInISomething // error!
  > = 0; // type 'd' does not satisfy the constraint

所以所有的东西都能运行... 但看起来并不美观。


另一个权宜之计是使用一个虚拟的class,其唯一目的是在您未添加正确属性时责骂您:

enum E { A = "a", B = "b" , C = "c"};
class CSomething implements Record<E, unknown> {
  a!: string;
  b!: number;
  c!: boolean;
}
interface ISomething extends CSomething {}

如果您忽略其中一个属性,将会出现错误:

class CSomething implements Record<E, unknown> { // error!
  a!: string;
  b!: number;
}
// Class 'CSomething' incorrectly implements interface 'Record<E, unknown>'.
// Property 'c' is missing in type 'CSomething'.

尽管可能你并不在意,但它不会警告你关于额外的属性。


总之,希望其中之一适合你。祝好运!


虚拟类的解决方案非常好 - 谢谢! 为什么ISomething不能直接实现Record<E, unknown>呢?似乎这可以在编译时简单地进行检查。 - poli
只有 class 定义可以使用 implements。 因此,interface ISomething implements Record<E, unknonwn> { ... } 是一个错误。 您可以使用 extends,例如 interface ISomething extends Record<E, unknown> {},但这不再警告您缺少的属性... ISomething 已经具有来自 E 的键和类型为 unknown 的属性。 - jcalz
这个能否变成实用工具类型以提高用户体验?例如:export type MatchesKeysOfEnum<T, Enum> = ??? - James
在4年前的答案评论区并不是寻求帮助的好地方。如果我有机会研究这个问题,我会这样做,但我不能保证。 - jcalz
对于未来的读者,你会想要使用新的满足操作符:const myObject = {} satisfies Record<MyEnum, string> - James

4

您可以只使用enum上的映射类型:

enum E { A = "a", B = "b"}

type AllE = { [P in E]: any }

let o: AllE = {
    [E.A]: 1,
    [E.B]: 2
};

let o2: AllE = {
    a: 1,
    b :2
}

Playground链接

编辑

如果您希望新建的对象保留原始属性类型,则需要使用一个函数。我们需要该函数来协助推断新创建的对象字面量的实际类型,同时仍限制其具有E的所有键。

enum E { A = "a", B = "b" };


function createAllE<T extends Record<E, unknown>>(o: T) : T {
    return o
}

let o = createAllE({
    [E.A]: 1,
    [E.B]: 2
}); // o is  { [E.A]: number; [E.B]: number; }

let o2 = createAllE({
    a: 1,
    b: 2
}) // o2 is { a: number; b: number; }


let o3 = createAllE({
    a: 2
}); // error

播放链接


(此为 TypeScript Playground 的链接,包含一些 TypeScript 代码。)

并不完全正确,因为我确实想指定[E.A]和[E.B]的类型(以及稍后的[E.C]等类型)。我将编辑问题以澄清这一点。 - poli
@poli编辑了问题以保留类型。如果您想要一个命名的接口,如果您没有所有字段,它会责备您,jcalz的答案处理得非常好。 - Titian Cernicova-Dragomir

3

使用 Record。只需将枚举类型放在第一个位置,不要使用 keyof 或其他任何东西。

export enum MyEnum {
  FIRST = 'First',
  SECOND = 'Second',
}

export const DISPLAY_MAP: Record<MyEnum, string> = {
  [MyEnum.FIRST]: 'First Map',
  [MyEnum.SECOND]: 'Second Map',
}

如果你缺少 TypeScript 中的任何一个属性,它都会警告你。

2
如果你不想使用字符串枚举,那么可以尝试以下方法:
enum E {
  A,
  B,
  C,
}

type ISomething = Record<keyof typeof E, number>;

const x: ISomething = {
  A: 1,
  B: 2,
  C: 3,
};

1
jcalz的上面的出色答案(即得票最高的答案)没有利用在TypeScript 4.9版本中发布的运算符,该运算符旨在解决这个问题。
你可以像这样使用它:
enum MyEnum {
  Value1,
  Value2,
  Value3,
}

const myObject = {
  [MyEnum.Value1]: 123,
  [MyEnum.Value2]: 456,
  [MyEnum.Value3]: 789,
} satisfies Record<MyEnum, number>;

这样就避免了其他答案中会出现的各种不必要的模板代码。
但是,请注意,你不能在接口或类型上使用"satisfies"运算符。所以在这种情况下,我们可以使用一个辅助函数,像这样:
enum MyEnum {
  Value1,
  Value2,
  Value3,
}

interface MyInterface {
  [MyEnum.Value1]: 123,
  [MyEnum.Value2]: 456,
  [MyEnum.Value3]: 789,
};

validateInterfaceMatchesEnum<MyInterface, MyEnum>();

然后你可以将这个辅助函数放入你的标准库中,它的样子是这样的:
/**
 * Helper function to validate that an interface contains all of the keys of an enum. You must
 * specify both generic parameters in order for this to work properly (i.e. the interface and then
 * the enum).
 *
 * For example:
 *
 * ```ts
 * enum MyEnum {
 *   Value1,
 *   Value2,
 *   Value3,
 * }
 *
 * interface MyEnumToType {
 *   [MyEnum.Value1]: boolean;
 *   [MyEnum.Value2]: number;
 *   [MyEnum.Value3]: string;
 * }
 *
 * validateInterfaceMatchesEnum<MyEnumToType, MyEnum>();
 * ```
 *
 * This function is only meant to be used with interfaces (i.e. types that will not exist at
 * run-time). If you are generating an object that will contain all of the keys of an enum, use the
 * `satisfies` operator with the `Record` type instead.
 */
export function validateInterfaceMatchesEnum<
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  T extends Record<Enum, unknown>,
  Enum extends string | number,
>(): void {}

(或者您可以添加 isaacscript-common-ts 的 npm 依赖项,它是一个提供类似这样的一些帮助函数的库。)

过时的答案:

jcalz上面的出色答案(即得票最高的答案)在最新版本的TypeScript中不起作用,会抛出以下错误:

所有类型参数都未使用。ts(62305)

这是因为这两个泛型参数未被使用。我们可以通过在变量名的第一个字符前加下划线来修复这个问题,像这样:

// Make copies of the objects that we need to verify so that we can easily
// re-use the code block below
type EnumToCheck = MyEnum;
type InterfaceToCheck = MyInterface;

// Throw a compiler error if InterfaceToCheck does not match the values of EnumToCheck
// From: https://dev59.com/ua3la4cB1Zd3GeqPPp5i
type KeysMissing = Exclude<EnumToCheck, keyof InterfaceToCheck>;
type ExtraKeys = {
  [K in keyof InterfaceToCheck]: Extract<EnumToCheck, K> extends never
    ? K
    : never;
}[keyof InterfaceToCheck];
type Verify<
  _Missing extends never = KeysMissing,
  _Extra extends never = ExtraKeys,
> = 0;

请注意,如果您使用ESLint,可能需要在某些行上添加一个eslint-disable-line注释,因为:
  1. "Verify"类型未使用
  2. 两个泛型参数未使用

你能给一个例子来说明如何实施吗? - four-eyes
你能给一个例子来说明如何实施吗? - undefined
我刚刚更新了我的回答,你可以阅读并点赞,如果有用的话。 - James

1
如果您可以使用类型(而不是接口),那么使用内置的Record类型实际上非常简单:
enum E { A = "a", B = "b", C="c"}
type ISomething = Record<E, { a: string, b: number, c: OtherType}>
type ISomethingElse = Record<E, { a: boolean, b: string, c: DifferentType}>

通过这种方式,Typescript会在ISomethingISomethingElse遗漏枚举中的任何键时发出警告。

我已经在Playground(版本3.9.2)中尝试过了,如果我在记录定义中省略任何值,它似乎不会警告我。 - Taytay
这很奇怪。它确实如此。例如,以下代码将无法编译:enum E { A = "a", B = "b", C = "c" }; type ISomething = Record; const foo: ISomething = { [E.A]: { a: "asdf", b: 123 } }; - Scottmas

0

如果对你来说,只要TypeScript编译器在没有将所有枚举值用作接口键时抛出错误就足够的话,以下是一种简洁的实现方式:

declare const checkIfAllKeysUsed = SomeInterface[SomeInterfaceKeysEnum];

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接