PAYATHON 2026

TypeScript 中如何通过单一 registry 实现类型安全的 dynamic dispatch?

技术老齐

可以只维护一个 registry,并从中推导参数类型。给 dispatch 使用的 registry 引用显式标注映射类型后,编译器就能通过同一个泛型键 K,关联 Verb<ParamsByType[K]> 和 action.params。

这个引用直接指向原对象即可,无须复制 registry,也不用手工维护另一份参数类型表。

为什么会出现 never

第二段代码中的 verbs 被推导为具体的对象类型:

{
  readonly goTo: Verb<number>;
  readonly attack: Verb<string>;
}

在报错的泛型函数中,通过这个具体对象进行索引访问时,编译器没有保留所需的类型关联,调用目标因此变成了函数联合:

((params: number) => void) | ((params: string) => void)

调用这样的函数联合时,实参必须同时满足所有可能的参数要求,也就是:

number & string // never

Action<K> 已经表达了 verbType 与 params 的对应关系。dispatch 一侧也需要用编译器能处理的映射形式表达这层关系。

as const 只能保留字面量类型并使属性只读,无法解决这里的关联问题。

完整实现

在 registry 中定义各个 verb,从中推导类型,再为 dispatch 建立带有映射类型注解的引用:

type Verb<Params> = {
  precondition: (params: Params) => boolean;
  execute: (params: Params) => void;
};

function defineVerb<Params>(verb: Verb<Params>): Verb<Params> {
  return verb;
}

// 唯一需要维护的 registry。
// 每个 verb 的参数类型与实现放在一起。
const registry = {
  goTo: defineVerb<number>({
    precondition: (v) => v > 0,
    execute: (v) => {
      console.log(v);
    },
  }),

  attack: defineVerb<string>({
    precondition: (s) => s.length > 0,
    execute: (s) => {
      console.log(s);
    },
  }),
};

// 从 registry 推导参数类型,不再手工声明对应关系。
type ParamsByType = {
  [K in keyof typeof registry]:
    Parameters<(typeof registry)[K]["execute"]>[0];
};

type VerbType = keyof ParamsByType;

type VerbRegistry = {
  [K in VerbType]: Verb<ParamsByType[K]>;
};

// 显式类型注解是关键。
// verbs 与 registry 指向同一个对象。
const verbs: VerbRegistry = registry;

type Action<K extends VerbType = VerbType> = {
  [P in K]: {
    verbType: P;
    params: ParamsByType[P];
  };
}[K];

function executeAction<K extends VerbType>(action: Action<K>): void {
  if (!verbs[action.verbType].precondition(action.params)) {
    throw new Error("Precondition not met");
  }

  verbs[action.verbType].execute(action.params);
}

defineVerb<number> 为对象中的两个函数提供了上下文类型,它们的参数都能推导为 number,不用分别标注。

const verbs: VerbRegistry = registry 是普通赋值,编译器会检查两者是否兼容。它没有使用类型断言,也没有创建第二个 registry 对象。

关联是如何保留下来的

映射类型表达了每个键与处理函数的对应关系:

type VerbRegistry = {
  [K in VerbType]: Verb<ParamsByType[K]>;
};

在 executeAction 中,处理函数和参数通过同一个键 K 关联:

// 对应的处理函数
Verb<ParamsByType[K]>

// 对应的参数
ParamsByType[K]

Action<K> 则保留了先映射、再索引的写法。当 K 是多个 verb 名称的联合时,它会生成各个合法 action 的联合:

type AnyAction = Action;

// 等价于:
// | { verbType: "goTo"; params: number }
// | { verbType: "attack"; params: string }

这样既可以执行具体的 action,也可以执行包含不同 action 的序列:

executeAction({ verbType: "goTo", params: 42 });
executeAction({ verbType: "attack", params: "sword" });

const actions: Action[] = [
  { verbType: "goTo", params: 1 },
  { verbType: "attack", params: "sword" },
];

for (const action of actions) {
  executeAction(action);
}

参数不匹配时,类型检查会报错:

// @ts-expect-error goTo 的 params 必须是 number
const invalidAction: Action = { verbType: "goTo", params: "field 1" };

新增 verb 时,只需在 registry 中添加定义,ParamsByType、VerbType 和 Action 都会随之更新。

哪些写法会丢失关联

如果把 Action 简化为两个独立的联合属性:

type BadAction = {
  verbType: VerbType;
  params: ParamsByType[VerbType];
};

类型检查就会允许下面这种错误组合:

const invalid: BadAction = {
  verbType: "goTo",
  params: "sword",
};

把 registry 中所有值的类型统一写成下面这样,也会改变原有的类型约定:

Verb<number | string>

这表示每个 verb 都能处理 number 和 string,与现有实现不同。

satisfies VerbRegistry 也不能替代这里的显式类型注解。它会检查兼容性,但会保留表达式自身的推导类型。dispatch 需要通过映射类型形式的引用来访问 registry。

接入 Valibot 时的边界

后续可以把 schema 和处理函数放在同一个 verb 定义中,从 schema 的解析输出推导参数类型。如果 schema 包含转换操作,需要区分原始输入类型和解析后的输出类型,处理函数应接收后者。

从 local storage 读取的 JSON 仍需在运行时校验。下面的类型断言不会检查数据是否有效:

const action = JSON.parse(raw) as Action;

校验时,需要确认 verbType 存在,并检查 params 是否符合该 verb 对应的 schema。解析成功后,再把结果交给 executeAction。

precondition 接收的是已经符合参数类型的数据,用来检查目标位置是否可达等游戏规则,不能替代对外部数据结构的校验。

备注:内容仅供参考。