typescript discriminated union 的 switch 语句的替代方案

alternative to switch statement for typescript discriminated union

我已经创建了 this playground,这是代码:

type BundlerError = Error;
type BundlerWarning = Error;

export type BundlerState =
  | { type: 'UNBUNDLED' }
  | { type: 'BUILDING'; warnings: BundlerWarning[] }
  | { type: 'GREEN'; path: string;  warnings: BundlerWarning[] }
  | { type: 'ERRORED'; error: BundlerError }

const logEvent = (event: BundlerState) => {
    switch (event.type) {
      case 'UNBUNDLED': {
        console.log('received bundler start');
        break;
      }
      case 'BUILDING':
        console.log('build started');
        break;
      case 'GREEN':
        if(event.warnings.length > 0) {
          console.log('received the following bundler warning');

          for (let warning of event.warnings) {
              warning
            console.log(warning.message);
          }
        }
        console.log("build successful!");
        console.log('manifest ready');
        break;
      case 'ERRORED':
        console.log("received build error:");
        console.log(event.error.message);
        break;
    }
}

BundlerState 是一个可区分的联合体,switch 缩小了类型。

问题是它无法扩展,而且大的扩展 switch 语句非常可怕。

有没有更好的方法我可以写这个并且仍然保持 nice 类型缩小?

你不能这样做:

const eventHandlers = {
  BUNDLED: (event: BundlerState) => event.type // type is not narrowed
  // etc,
};

const logEvent = (event: BundlerState) => eventHandlers['BUNDLED'](event);

因为类型没有缩小。

也许您可以使用处理程序映射,其中键是事件类型(UNBUNDLED、BUILDING 等),值是需要调用的处理程序:

type BundlerError = Error;
type BundlerWarning = Error;

export type BundlerState =
  | { type: 'UNBUNDLED' }
  | { type: 'BUILDING'; warnings: BundlerWarning[] }
  | { type: 'GREEN'; path: string;  warnings: BundlerWarning[] }
  | { type: 'ERRORED'; error: BundlerError }

const eventHandlers = {
  UNBUNDLED: (event: BundlerState) => console.log('received bundler start'),
  BUILDING: (event: BundlerState) => console.log('build started'),
  GREEN: (event: BundlerState) => console.log('received the following bundler warning'),
  ERRORED: (event: BundlerState) => console.log("received build error:"),
};

const logEvent = (event: BundlerState) => eventHandlers[event.type](event);

这里link去游乐场。

有两种方法

const eventHandlers = {
  BUNDLED: (event: Extract<BundlerState, { type: 'BUILDING' }>) => event. 
  // etc,
};


type BundlerBuildingState = Extract<BundlerState, { type: 'BUILDING' }> // will be  { type: "link"; url: string; }

const eventHandlers = {
  BUNDLED: (event: BundlerBuildingState) => event. 
  // etc,
};

您的问题的解决方案是使用 OOP 和多态性。

BundlerState成为声明public接口的抽象基础class:

export abstract class BundlerState {
  public abstract logEvent(): void;
}

然后为type的每个值扩展它:

export class UnbundledState extends BundlerState {
  public logEvent(): void {
    console.log('received bundler start');
  }
}

export class BuildingState extends BundlerState {
  public constructor(private warnings: BundlerWarning[]) {}

  public logEvent(): void {
    console.log('build started');
  }
}

export class GreenState extends BundlerState {
  public constructor(private path: string; private warnings: BundlerWarning[]) {}

  public logEvent(): void {
    if(event.warnings.length > 0) {
      console.log('received the following bundler warning');

      for (let warning of event.warnings) {
        console.log(warning.message);
      }
    }
    console.log("build successful!");
    console.log('manifest ready');
  }
}

export class ErroredState extends BundlerState {
  public constructor(private error: BundlerError) { }

  public logEvent(): void {
    console.log("received build error:");
    console.log(event.error.message);
  }
}

这样可以在不修改现有代码的情况下添加新类型。

用法

用户代码略有改动。而不是:

const state: BUndlerState = { type: 'BUILDING'; warnings: [ warning1, warning2 ] };
logState(state);

变成:

const state: BundlerState = new BuildingState([warning1, warning2]);
state.logState();

进一步讨论

  • 你注意到 属性 type 发生了什么吗?
    它消失了(因为不再需要它);它的值现在编码在类型本身中(进入 class 名称)。

  • OOP 通常(显然)比过程方法产生更多的代码。建议的解决方案有 42 行(包括空行),而原始解决方案只有 33 行。

    但是每个 class 可以而且应该保留在自己的文件中。这导致更小的代码片段更容易阅读和理解。

此外,可以添加新类型 BundlerState(新 classes)(在新文件中)而不更改现有文件。

  • 根本不需要 class;可以改用接口。状态 classes 没有公共属性(字段 type 消失了,因为它不需要)。它们的共同点是一种行为(logEvent() 方法),这可以用接口表示:

    interface BundlerState {
       logEvent(): void
    }
    

    然后每个状态 class 将 implement BundlerState 而不是扩展它。用户密码不变。

您需要使用 Extract<BundlerState, {type: 'TYPE'} 缩小事件处理程序 lambda 中的 BundlerState 参数。你 想要确保你的参数匹配事件处理程序映射中的键(例如 eventHandlers['TYPE'](event: Extract<BundlerState, { type: 'TYPE' }>) => any 类型)。这可以通过创建一个特殊的EventHandlers 强制执行键和事件处理程序的 lambda 签名之间这种关系的类型。

使用前面提到的 Extract<...> 方法定义一个类型来缩小 BundlerState 也可以显着减少句法的丑陋。

// generic parameter is optional; if no generic is passed, returns the full BundleState union
type NarrowedBundlerState<T extends BundlerState["type"] = BundlerState["type"]> = Extract<BundlerState, { type: T }>;
// event handler map that ensures a relationship between the key and the event handler's lambda signature
type EventHandlers = { [T in BundlerState["type"]]: (event: NarrowedBundlerState<T>) => any; };

const eventHandlers: EventHandlers = {
    // allowed entries; we can also access the narrowed type's properties correctly
    UNBUNDLED: (event: NarrowedBundlerState<"UNBUNDLED">) => event.type,
    BUILDING: (event: NarrowedBundlerState<"BUILDING">) => event.warnings,
    GREEN: (event: NarrowedBundlerState<"GREEN">) => event.path,
    ERRORED: (event: NarrowedBundlerState<"ERRORED">) => event.type,
};
const badEventHandlers: Partial<EventHandlers> = {
    // a non-allowed entry because the key and 'type' parameter do not match
    ERRORED: (event: NarrowedBundlerState<"GREEN">) => event.type,
};

const logEvent = (event: BundlerState) => {
    // a caveat is you need to cast the retrieved event handler to a more general event handler lambda signature
    (eventHandlers[event.type] as (event: BundlerState) => any)(event);
    // alternatively you could cast to (params: NarrowedBundlerState<typeof event.type>) => any
    // however, it resolves to (event: BundlerState) => any anyways
};

如果您不想在事件处理程序映射中定义所有可能的事件类型,您可以使用 Partial<EventHandlers> 类型。

这是我经常使用的模式(或其变体)。

type BundlerStatesDef = {
   UNBUNDLED: {}
   BUILDING: { warnings: BundlerWarning[] }
   GREEN: { path: string; warnings: BundlerWarning[] }
   ERRORED: { error: BundlerError }
}
type BundlerStateT = keyof BundlerStatesDef
type BundlerStates = { [K in BundlerStateT]: { type: K } & BundlerStatesDef[K] }
type BundlerHandler<K extends BundlerStateT> = (params: BundlerStates[K]) => void
type BundlerHandlers = { [K in BundlerStateT]: BundlerHandler<K> }

使用上面定义的类型,您可以得到一个非常符合人体工程学的实现,如下所示:

const handlers: BundlerHandlers = {
  UNBUNDLED: params => console.log(params),
  BUILDING: params => console.log(params),
  GREEN: params => console.log(params),
  ERRORED: params => console.log(params)
}

const logEvent = <E extends BundlerStateT>(event: BundlerStates[E]) =>
  (handlers[event.type] as BundlerHandler<E>)(event)

PLAYGROUND


更贴近您的原始定义,甚至更不冗长,您可以这样做:

type BundlerError = Error
type BundlerWarning = Error

export type BundlerState =
  | { type: 'UNBUNDLED' }
  | { type: 'BUILDING'; warnings: BundlerWarning[] }
  | { type: 'GREEN'; path: string;  warnings: BundlerWarning[] }
  | { type: 'ERRORED'; error: BundlerError }

export type BundlerHandlers = { [K in BundlerState['type']]: (params: Extract<BundlerState, { type: K }>) => void }

const handlers: BundlerHandlers = {
  UNBUNDLED: params => console.log(params),
  BUILDING: params => console.log(params),
  GREEN: params => console.log(params),
  ERRORED: params => console.log(params)
}

const logEvent = (event: BundlerState) =>
  (handlers[event.type] as (params: Extract<BundlerState, { type: typeof event['type'] }>) => void )(event)

PLAYGROUND

我注意到了 fp-ts 标签,所以我想我会在考虑该库的情况下给出方法。 fp-ts 定义了很多 fold 操作,这些操作基本上实现了您正在寻找的各种代数类型的结果。一般的想法是定义一个函数来为您缩小范围,然后为每种情况定义处理程序。

简单示例

import { Option, some, none, fold } from 'fp-ts/lib/Option';
const x: Option<number> = some(1);
const y: Option<number> = none;

const printSomeNumber = fold(
  () => console.log('No number'),
  (n) => console.log(n);
);

printSomeNumber(x); // Logs 1
printSomeNumber(y); // Logs "No number" 

所以对于你的类型,你可以这样写:

import { absurd } from 'fp-ts';
type BundlerError = Error;
type BundlerWarning = Error;
enum StateType {
  Unbundled = 'UNBUNDLED',
  Building = 'BUILDING',
  Green = 'GREEN',
  Errored = 'ERRORED',
}
type Unbundled = { type: StateType.Unbundled; };
type Building = { type: StateType.Building; warnings: BundlerWarning[]; };
type Green = { type: StateType.Green; path: string; warnings: BundlerWarning[]; };
type Errored = { type: StateType.Errored; error: BundlerError };
export type BundlerState = Unbundled | Building | Green | Errored;

const fold = <ReturnType extends any>(
  a: (state: Unbundled) => ReturnType,
  b: (state: Building) => ReturnType,
  c: (state: Green) => ReturnType,
  d: (state: Errored) => ReturnType,
) => (state: BundlerState): ReturnType => {
  switch(state.type) {
    case StateType.Unbundled:
        return a(state);
    case StateType.Building:
        return b(state);
    case StateType.Green:
        return c(state);
    case StateType.Errored:
        return d(state);
    default:
        // This is a helper from fp-ts for throwing when the value should be never.
        return absurd(state);
  }
};

const logType = fold(
    (state) => console.log(state.type),
    (state) => console.log(state.type),
    (state) => console.log(state.type),
    (state) => console.log(state.type),
);

Playground 这样您就可以检查每个状态。

所以 fold 是一个高阶函数,用于为您的类型创建处理程序(与 Option 的处理方式相同)。