在处理可区分的联合时减少代码重复而不削弱类型

Reduce code duplication without weakening types when processing a discriminated union

我正在尝试弄清楚如何在不削弱类型检查的情况下处理有区别的联合时消除一些重复代码:

const handlers = {
  foo: (input: string): void => console.log(`foo ${input}`),
  bar: (input: number): void => console.log(`bar ${input}`),
};

type Handlers = typeof handlers;
type Values<T> = T[keyof T];
type DiscriminatedInput = Values<{
  [Id in keyof Handlers]: {type: Id; value: Parameters<Handlers[Id]>[0]};
}>;

const inputs: DiscriminatedInput[] = [
  JSON.parse('{"type": "foo", "value": "hello world"}'),
  JSON.parse('{"type": "bar", "value": 42}'),
];
for (const input of inputs) {
  // This doesn't work:
  //
  //     handlers[input.type](input.value);
  //                          ^^^^^^^^^^^
  //         error: Argument of type 'string | number' is not assignable to
  //         parameter of type 'never'.
  //
  // So I do this instead, which has a lot of duplication and must be kept in
  // sync with `handlers` above:
  switch (input.type) {
    case 'foo': handlers[input.type](input.value); break;
    case 'bar': handlers[input.type](input.value); break;
    default: ((exhaustiveCheck: never) => {})(input);
  }
}

在上面的 for 循环中,handlers[input.type] 保证是第一个参数总是匹配 input.value 类型的函数,而不管 input.type。在我看来,TypeScript 应该能够看到这一点,但它没有。

我是不是做错了什么,或者这是 TypeScript 的限制?

如果是 TypeScript 的限制,是否有现有的错误报告?我可以做些什么来帮助 TypeScript 将 input 缩小为 foo- 或 bar- 特定类型,以便我可以消除 switch 语句?或者重构 DisciminatedInput?

我可以使用类型断言来削弱类型检查,但这会增加复杂性并降低可读性,只是为了解决语言限制。我宁愿使用语言而不是反对它。

此答案是对您最新问题修订的回应,:

You can view an assertion as widening (weakening) types, but what's really happening is that you are artificially narrowing (strengthening) the types by asserting what gets parsed from the JSON strings (which is actually any), and then having to fight against what you asserted to the compiler.

如果你:

  • 不想使用 type assertion,并且
  • 必须根据您问题中提供的架构解析 JSON 输入

然后您可以重构您的处理程序以包含每个处理程序输入参数的 typeof 运行时值。这将使您能够验证它与从每个 JSON 对象解析的 typeof 输入值之间是否存在相关性:使用 type predicate 函数来满足编译器的要求。

总结:这用谓词替换了讨论的断言,谓词使用运行时检查在调用其关联的处理程序之前强制对解析的 JSON 输入进行简单验证。

下面是此类重构的示例:

TS Playground

type Values<T> = T[keyof T];
type Handler<Param> = (input: Param) => void;

type HandlerData<Param> = {
  inputType: string;
  fn: Handler<Param>;
};

/**
 * This identity function preservees the type details of the provided
 * argument object, while enforcing that it extends the constraint (which
 * is used later in a predicate to ensure a level of type-safety of the parsed JSON)
 */
function createHandlers <T extends Record<string, HandlerData<any>>>(handlers: T): T {
  return handlers;
}

const handlers = createHandlers({
  foo: {
    inputType: 'string',
    fn: (input: string): void => console.log(`foo ${input}`),
  },
  bar: {
    inputType: 'number',
    fn: (input: number): void => console.log(`bar ${input}`),
  },
});

type Handlers = typeof handlers;

type DiscriminatedInput = Values<{
  [Key in keyof Handlers]: {
    type: Key;
    value: Parameters<Handlers[Key]['fn']>[0];
  };
}>;

// This type is required for the usage in the following predicate
type HandlerDataWithInput<Param, Value> = HandlerData<Param> & { value: Value };

function dataIsTypeSafe <T = DiscriminatedInput['value']>(data: HandlerDataWithInput<any, any>): data is HandlerDataWithInput<T, T> {
  return typeof data.value === data.inputType;
}

const inputs: DiscriminatedInput[] = [
  JSON.parse('{"type": "foo", "value": "hello world"}'),
  JSON.parse('{"type": "bar", "value": 42}'),
];

for (const input of inputs) {
  const data = {...handlers[input.type], value: input.value};
  if (dataIsTypeSafe(data)) data.fn(data.value);
}