单一注册表的动态派发

前端开发 2026-07-09

在我的TypeScript游戏中,我想建模一个可扩展的动词系统,其中有预定义的动词(例如“move”、“attack”之类的函数定义),用户可以从它们构建一系列带参数的动作(例如“move to field 1”、“attack with sword”这类的函数调用)。这些动词可以有前置条件和效果。如何在保持类型安全的前提下建模,同时确保只有一个动词注册表?使用 相关联联合(“分布式”)类型,我已经走到这一步:

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

type ParamsByType = { goTo: number; attack: string };

const goTo: Verb<ParamsByType["goTo"]> = {
  precondition: (v) => v > 0,
  execute: (v) => {
    console.log(v);
  },
};

const attack: Verb<ParamsByType["attack"]> = {
  precondition: (s) => s.length > 0,
  execute: (s) => {
    console.log(s);
  },
};

const verbs: { [K in keyof ParamsByType]: Verb<ParamsByType[K]> } = {
  goTo,
  attack,
} as const;

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

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

无编译错误的示例

但请注意,动词的类型与逻辑分别分散在 ParamsByTypeverbs 这两个部分。我希望把每个动词的类型和逻辑封装在同一个定义里(稍后引入一个Valibot架构,用来解析存储到本地存储中的动作)。在保持相关联联合类型的类型安全的前提下,如何实现?一旦从 VerbMap 派生出 ParamsByType,就会出现编译错误:

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

const goTo: Verb<number> = {
  precondition: (v) => v > 0,
  execute: (v: number) => {
    console.log(v);
  },
};

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

const verbs = {
  goTo,
  attack,
} as const;

type VerbMap = typeof verbs;

type ParamsByType = {
  [K in keyof VerbMap]: Parameters<VerbMap[K]["execute"]>[0];
};

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

function executeAction<K extends keyof ParamsByType>(action: Action<K>) {
  if (!verbs[action.verbType].precondition(action.params)) { // Argument of type 'string | number' is not assignable to parameter of type 'never'.
    throw new Error("Precondition not met");
  }
  verbs[action.verbType].execute(action.params); // Argument of type 'string | number' is not assignable to parameter of type 'never'.
}

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

带编译错误的示例

解决方案

处理相关联联合类型的唯一受支持的方法(如 [microsoft/TypeScript#30581] 所述)就是按照 [microsoft/TypeScript#47109] 的描述来做。正如你所看到的,这涉及将你的类型改写为对一个映射类型进行显式的带泛型索引的访问。

这一做法要求TypeScript能够“看到”在值层面的泛型索引操作与类型层面的等价泛型索引操作之间的对应关系。verbs 的类型必须显式地是类似于映射类型 {[P in keyof ParamsByType]: Verb<P>} 的类型,以便用泛型类型 K extends keyof ParamsByTypeaction.verbType 对它进行索引时,能够得到泛型类型 Verb<K>

如果 verbs 的类型只是与映射类型在结构上等价,那么该方法就会失效。因此像 const verbs = {⋯} 这样的声明,得到 const verbs: {goTo: Verb<number>}; attack: Verb<string>} 的结果,是行不通的。TypeScript不能对该类型进行抽象推理。它不能“注意到”该类型可以被改写为 {[P in keyof ParamsByType]: Verb<P>}。因此在进行索引时,它会回退到非泛型的版本,最终得到类似 Verb<number> | Verb<string> 的联合类型,相关性也就被打破。

最简单的修复方式是采用你之前那种“分散”的做法:先定义 VerbMap 类型,然后再以它来定义 verbs

但如果你想先定义映射对象再从中导出 ParamsByType,也没问题。你需要随后也从中派生出映射类型。你将从映射对象开始,然后你会需要把该对象赋值给一个新类型。这也可以通过新变量来完成。比如:

const _verbs = { goTo, attack } as const; // <-- renamed out of the way
type VerbMap = typeof _verbs; // unchanged
type ParamsByType = { [K in keyof VerbMap]: Parameters<VerbMap[K]["execute"]>[0]; }; // unchanged
const verbs: { [K in keyof VerbMap]: Verb<ParamsByType[K]> } = _verbs; // new type

我们已经把 verbs 改名为 _verbs,以便日后用名称 verbs 来表示那个对TypeScript更有用的对象的类型。是的,看起来好像没有发生任何变化:_verbs 的类型和 verbs 的类型通常会显示为相同的结果。但内部表示的差异仍然很重要。从TypeScript的角度看,_verbs 的类型与对 ParamsByType 的依赖无关(可看到的依赖都是反方向的)。而 verbs 的类型则明确就是你需要用来索引的映射类型。

如果TypeScript能不经人工整理就直接注意到这些相关性,那就太好了。但它做不到。因此要让它工作,你就需要经过这些周折。

Playground代码示例

站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章