我如何要求枚举具有某些成员

How do I require that an enum has certain members

我正在设计一个涉及一些有限状态机的库 API,所以假设该库导出以下接口:

export interface FSM<TStates> {
  state: TStates
  // ... other properties
}

库要求状态机具有状态 'started''finished'。我一直在尝试将此约束编码到类型系统中,但没有取得多大成功。

到目前为止,我已尝试将此约束实现为枚举:

export enum BaseState {
  STARTED = 'started',
  FINISHED = 'finished',
}

export interface FSM<TStates extends BaseState> {
  state: TStates
  // ... other properties
}

enum MyState {
  STARTED = 'started',
  OTHER = 'other',
  FINISHED = 'finished',
}

// Type 'MyState' does not satisfy the constraint 'BaseState'.ts(2344)
let fsm: Fsm<MyState>

我试过联合类型

export type BaseState = 'started' | 'finished';

export interface FSM<TStates extends BaseState> {
  state: TStates
  // ... other properties
}

type MyState = 'started' | 'finished' | 'other'

// Type 'MyState' does not satisfy the constraint 'BaseState'.
//   Type '"other"' is not assignable to type 'BaseState'.ts(2344)
let fsm: Fsm<MyState>

是否可以在打字稿中表示这种约束?

UPD:顺便说一句,如果你能做到,并不意味着你应该。也许评论中关于重构问题并将 BaseState & TStates 用于 state 的建议更好。尽管在这种情况下您不能使用这些属性定义自己的枚举。

无论如何

首先是工会。正如您已经了解的那样,您不能将 TStates 类型参数限制为扩展 'started' | 'finished',因为那样您将无法添加额外的字段。但是,您可以要求 'started' | 'finished' 扩展 TStates。一种方法是这样的:

// Allow TStates to be any string
export type FSM<TStates extends string> =
  'started' | 'finished' extends TStates ?
    {
      state: TStates
    } :
    never

如果 'started' | 'finished' 没有扩展 TStates,那么您将无法为 FSM<TStates> 类型的变量赋值。您还可以添加一个 TStates 本身不是 string 的检查,这样您就不能只使用 FSM<string>

export type FSM<TStates extends string> =
  'started' | 'finished' extends TStates ?
  string extends TStates ?
    never :
    {
      state: TStates
    } :
    never

这将只接受字符串联合,因为目前无法创建像 Exclude<string, 'foo'> 这样的类型。如果 TStates extends string,那么它要么是 string 本身,要么是字符串文字的并集。

这看起来有点难看,但实用程序类型可能会有所帮助

type IfIncludesStartedFinished<TStates extends string, T> = 
  'started' | 'finished' extends TStates ? 
    string extends TStates ? never : T : never

export type FSM<TStates extends string> = IfIncludesStartedFinished<
  TStates,
  {
    state: TStates
  }
>

虽然你应该小心使用 never,因为类型 never 的变量可以分配给任何其他变量。也许使用不透明的错误类型会有所帮助

declare const errorTypeSymbol: unique symbol
type ErrorType<TMessage extends string> = TMessage & {error: errorTypeSymbol}

type IfIncludesStartedFinished<TStates extends string, T> = 
  'started' | 'finished' extends TStates ?
    string extends TStates ? 
      ErrorType<"TStates must be a union of strings, not a string"> :
      T :
      ErrorType<"TStates must include started and finished">

现在,如果出现问题,当您将鼠标悬停在错误上时,您会在打字稿的投诉之间的某处看到一条错误消息。虽然你还是要小心。


现在说到枚举,有点hacky。打字稿中字符串枚举背后的想法是,如果您更改一个的值,代码不会注意到它并且以相同的方式工作。如果您要求枚举包含一些值,则不再是这种情况,因此也许您应该考虑不这样做。但我不认为这是不可饶恕的罪过,你可以完全不在乎。

最近在 ts 中,可以使用模板文字类型将枚举值的类型作为字符串的并集,例如 `${Enum}`是降价的一部分)。您可以利用它来改变我上面描述的类型

export type FSM<TStates extends string> = IfIncludesStartedFinished<
  // In case TStates is a enum, turn it into a union of strings
  `${TStates}`,
  {
    state: TStates
  }
>

顺便说一下,这将允许您对 TStates

使用字符串联合和枚举