Learn
TypeScript/22-project-typed-cli

项目实战:类型安全的任务 CLI

学完二十一章语法,最后我们把它们串成一个完整的东西:一个任务管理 CLI 的内核。它不依赖任何第三方包,全部用纯 TypeScript 实现,包含三个可以直接搬进真实项目的组件:

  1. 泛型事件总线:用一张「事件名 → 载荷类型」的映射表,让 emit 和 on 双向类型安全。
  2. Result 化的领域存储:所有可预期的失败都编码在返回值里,调用方无法忽略。
  3. 命令注册表:每条命令自带参数解析器,参数类型在注册时被推导出来,调度器却只面对统一接口。

用到的知识点:泛型约束、映射类型、索引访问、可辨识联合、参数属性、类型擦除封装。建议边读边在 Playground 里改代码验证。

1. 泛型事件总线

1.1 为什么需要事件映射表

朴素的事件总线长这样:

class NaiveBus {
  on(type: string, fn: (payload: any) => void): void { /* ... */ }
  emit(type: string, payload: any): void { /* ... */ }
}

它有两个致命问题:事件名写错不报错,载荷结构完全不受约束。改进的关键是引入一个事件映射表类型:

type AppEvents = {
  "user:login": { id: number; name: string };
  "user:logout": { id: number };
};

然后让总线以它为类型参数。on 与 emit 都用 K extends keyof E 约束事件名,用 E[K](索引访问类型)确定载荷类型。这样事件名和载荷就被绑死了。

1.2 实现中的类型难点

内部存储必须擦除类型(一个 Map 里放着各种不同签名的回调),但对外接口要保持精确。这里的技巧是把内部存储的回调参数声明为 never:

  • 任何 (p: E[K]) => void 都能赋给 (p: never) => void(参数逆变,never 可赋给一切),所以存入不需要断言;
  • 取出调用时才做一次双重断言,且断言被封装在 emit 内部,调用方感知不到。
泛型事件总线
type EventMap = Record<string, unknown>;
 
class EventBus<E extends EventMap> {
  private readonly handlers = new Map<keyof E, ((payload: never) => void)[]>();
 
  on<K extends keyof E>(type: K, fn: (payload: E[K]) => void): () => void {
    const list: ((payload: never) => void)[] = this.handlers.get(type) ?? [];
    list.push(fn);
    this.handlers.set(type, list);
    return () => {
      this.off(type, fn);
    };
  }
 
  once<K extends keyof E>(type: K, fn: (payload: E[K]) => void): void {
    const wrapper: (payload: E[K]) => void = (payload) => {
      this.off(type, wrapper);
      fn(payload);
    };
    this.on(type, wrapper);
  }
 
  off<K extends keyof E>(type: K, fn: (payload: E[K]) => void): void {
    const list = this.handlers.get(type);
    if (list === undefined) return;
    const i = list.indexOf(fn);
    if (i >= 0) list.splice(i, 1);
  }
 
  emit<K extends keyof E>(type: K, payload: E[K]): void {
    const list = this.handlers.get(type);
    if (list === undefined) return;
    for (const fn of list.slice()) {
      (fn as unknown as (p: E[K]) => void)(payload);
    }
  }
 
  count(type: keyof E): number {
    return this.handlers.get(type)?.length ?? 0;
  }
}
 
// ---- 使用 ----
type AppEvents = {
  "user:login": { id: number; name: string };
  "user:logout": { id: number };
  "metric": { key: string; value: number };
};
 
const bus = new EventBus<AppEvents>();
 
const offLogin = bus.on("user:login", (p) => {
  console.log("登录:", p.name, "id=" + p.id);
});
bus.on("metric", (p) => {
  console.log("指标:", p.key, "=", p.value);
});
bus.once("user:logout", (p) => {
  console.log("登出(只触发一次):", p.id);
});
 
bus.emit("user:login", { id: 1, name: "Ada" });
bus.emit("metric", { key: "latency_ms", value: 12 });
bus.emit("user:logout", { id: 1 });
bus.emit("user:logout", { id: 1 });
 
offLogin();
bus.emit("user:login", { id: 2, name: "Grace" });
 
console.log("剩余订阅: login =", bus.count("user:login"), ", metric =", bus.count("metric"));
 
// 下面几行如果取消注释,都会在编译期被拦下:
// bus.emit("user:login", { id: 3 });     // 缺少 name
// bus.emit("user:signup", { id: 3 });    // 事件名不存在
// bus.on("metric", (p) => p.missing);    // 载荷上没有这个字段
console.log("类型契约生效");
💡事件名用「域:动作」格式

"task:added" 这种带命名空间的事件名,在编辑器里输入 "task: 就能补全出该领域的全部事件。配合模板字面量类型,还能进一步约束「所有事件名必须符合某个模式」。

2. Result 化的领域存储

2.1 划分错误类别

任务存储会遇到两类问题:

  • 可预期的业务失败:标题为空、任务不存在、重复完成——用 Result 返回。
  • 不该发生的 bug:内部状态不一致——用 invariant 抛错。

只有第一类进入 Result。把所有异常都塞进 Result 会让类型噪音淹没真正需要处理的分支。

2.2 不可变更新

注意 Task 的 id 是 readonly,tags 是 readonly string[]。修改任务时我们不是原地改字段,而是创建一个新对象放回 Map。这样任何持有旧引用的代码都不会看到意外变化,也让后续加撤销、加事件溯源变得简单。

Result 化的任务存储
type Task = {
  readonly id: number;
  readonly title: string;
  readonly done: boolean;
  readonly tags: readonly string[];
};
 
type Ok<T> = { ok: true; value: T };
type Err = { ok: false; error: string };
type Result<T> = Ok<T> | Err;
 
function ok<T>(value: T): Ok<T> {
  return { ok: true, value };
}
function err(error: string): Err {
  return { ok: false, error };
}
 
type Filter = { done?: boolean; tag?: string };
 
class TaskStore {
  private readonly tasks = new Map<number, Task>();
  private nextId = 1;
 
  add(title: string, tags: readonly string[] = []): Result<Task> {
    const trimmed = title.trim();
    if (trimmed === "") return err("标题不能为空");
    if (trimmed.length > 60) return err("标题最多 60 个字符");
    const task: Task = { id: this.nextId, title: trimmed, done: false, tags: [...tags] };
    this.nextId += 1;
    this.tasks.set(task.id, task);
    return ok(task);
  }
 
  complete(id: number): Result<Task> {
    const cur = this.tasks.get(id);
    if (cur === undefined) return err("任务 " + id + " 不存在");
    if (cur.done) return err("任务 " + id + " 已经完成过了");
    const updated: Task = { ...cur, done: true };
    this.tasks.set(id, updated);
    return ok(updated);
  }
 
  remove(id: number): Result<number> {
    if (!this.tasks.delete(id)) return err("任务 " + id + " 不存在");
    return ok(id);
  }
 
  list(filter?: Filter): Task[] {
    let items = [...this.tasks.values()];
    if (filter !== undefined) {
      const wantDone = filter.done;
      if (wantDone !== undefined) items = items.filter((t) => t.done === wantDone);
      const tag = filter.tag;
      if (tag !== undefined) items = items.filter((t) => t.tags.includes(tag));
    }
    return items;
  }
 
  stats(): { total: number; done: number; pending: number } {
    const all = [...this.tasks.values()];
    const done = all.filter((t) => t.done).length;
    return { total: all.length, done, pending: all.length - done };
  }
}
 
// ---- 演示 ----
const store = new TaskStore();
 
const inputs: { title: string; tags: string[] }[] = [
  { title: "写完 TypeScript 教程", tags: ["docs", "ts"] },
  { title: "准备周会材料", tags: ["work"] },
  { title: "   ", tags: [] },
];
 
for (const input of inputs) {
  const r = store.add(input.title, input.tags);
  console.log(r.ok ? "添加成功 #" + r.value.id + " " + r.value.title : "添加失败: " + r.error);
}
 
const done1 = store.complete(1);
console.log(done1.ok ? "已完成: " + done1.value.title : "错误: " + done1.error);
 
const done2 = store.complete(1);
console.log(done2.ok ? "已完成: " + done2.value.title : "错误: " + done2.error);
 
const gone = store.remove(99);
console.log(gone.ok ? "已删除" : "错误: " + gone.error);
 
console.log("全部:", store.list().map((t) => t.title).join(" / "));
console.log("已完成:", store.list({ done: true }).map((t) => t.title).join(" / "));
console.log("按标签 work:", store.list({ tag: "work" }).map((t) => t.title).join(" / "));
console.log("统计:", JSON.stringify(store.stats()));
⚠️Result 不要泄漏到深处

Result 适合作为模块边界的返回类型。如果内部十几个私有方法层层传递 Result,代码会被 if (!r.ok) return r; 淹没。内部实现可以放心用异常,在公开方法处统一转成 Result。

3. 类型安全的命令注册表

3.1 核心矛盾

每条命令的参数类型都不一样:add 需要 { title, tags },done 需要 { id },help 什么都不需要。但调度器必须把它们放进同一个 Map 里统一调用。

  • 如果注册表存 CommandSpec<unknown>,run 的参数是逆变位置,类型不兼容;
  • 如果存 CommandSpec<any>,类型安全就没了。

3.2 解法:定义时保留类型,注册时擦除

关键是把「解析 + 执行」在定义命令的那一刻就闭包起来,对外只暴露一个统一签名 (argv, ctx) => Result<string[]>:

type CommandSpec<A> = {
  name: string;
  usage: string;
  parse: (argv: readonly string[]) => Result<A>;
  run: (args: A, ctx: Ctx) => Result<string[]>;
};
 
type AnyCommand = {
  name: string;
  usage: string;
  exec: (argv: readonly string[], ctx: Ctx) => Result<string[]>;
};
 
function defineCommand<A>(spec: CommandSpec<A>): AnyCommand {
  return {
    name: spec.name,
    usage: spec.usage,
    exec: (argv, ctx) => {
      const parsed = spec.parse(argv);
      return parsed.ok ? spec.run(parsed.value, ctx) : parsed;
    },
  };
}

A 只在 defineCommand 的作用域内可见,闭包外面完全看不到它。这是**存在类型(existential type)**在 TypeScript 里的常见模拟手法:一次断言都不需要,类型安全和统一接口兼得。

3.3 完整程序

下面是把三个组件拼起来的完整实现。它模拟执行一段命令脚本,你可以直接修改 script 数组里的命令来试验。

完整的任务管理 CLI 内核
// ===================== 1. Result =====================
type Ok<T> = { ok: true; value: T };
type Err = { ok: false; error: string };
type Result<T> = Ok<T> | Err;
 
function ok<T>(value: T): Ok<T> {
  return { ok: true, value };
}
function err(error: string): Err {
  return { ok: false, error };
}
 
// 安全取下标,兼容开启 noUncheckedIndexedAccess 的项目
function at(argv: readonly string[], i: number): string | undefined {
  return i < argv.length ? argv[i] : undefined;
}
 
// ===================== 2. 事件总线 =====================
class EventBus<E extends Record<string, unknown>> {
  private readonly handlers = new Map<keyof E, ((payload: never) => void)[]>();
 
  on<K extends keyof E>(type: K, fn: (payload: E[K]) => void): void {
    const list: ((payload: never) => void)[] = this.handlers.get(type) ?? [];
    list.push(fn);
    this.handlers.set(type, list);
  }
 
  emit<K extends keyof E>(type: K, payload: E[K]): void {
    const list = this.handlers.get(type);
    if (list === undefined) return;
    for (const fn of list.slice()) {
      (fn as unknown as (p: E[K]) => void)(payload);
    }
  }
}
 
// ===================== 3. 领域模型 =====================
type Task = {
  readonly id: number;
  readonly title: string;
  readonly done: boolean;
  readonly tags: readonly string[];
};
 
type AppEvents = {
  "task:added": { task: Task };
  "task:completed": { task: Task };
  "task:removed": { id: number };
  "command:failed": { command: string; reason: string };
};
 
type Filter = { done?: boolean; tag?: string };
 
class TaskStore {
  private readonly tasks = new Map<number, Task>();
  private nextId = 1;
 
  constructor(private readonly bus: EventBus<AppEvents>) {}
 
  add(title: string, tags: readonly string[]): Result<Task> {
    const trimmed = title.trim();
    if (trimmed === "") return err("标题不能为空");
    if (trimmed.length > 60) return err("标题最多 60 个字符");
    const task: Task = { id: this.nextId, title: trimmed, done: false, tags: [...tags] };
    this.nextId += 1;
    this.tasks.set(task.id, task);
    this.bus.emit("task:added", { task });
    return ok(task);
  }
 
  complete(id: number): Result<Task> {
    const cur = this.tasks.get(id);
    if (cur === undefined) return err("任务 " + id + " 不存在");
    if (cur.done) return err("任务 " + id + " 已经完成过了");
    const updated: Task = { ...cur, done: true };
    this.tasks.set(id, updated);
    this.bus.emit("task:completed", { task: updated });
    return ok(updated);
  }
 
  remove(id: number): Result<number> {
    if (!this.tasks.delete(id)) return err("任务 " + id + " 不存在");
    this.bus.emit("task:removed", { id });
    return ok(id);
  }
 
  list(filter: Filter): Task[] {
    let items = [...this.tasks.values()];
    const wantDone = filter.done;
    if (wantDone !== undefined) items = items.filter((t) => t.done === wantDone);
    const tag = filter.tag;
    if (tag !== undefined) items = items.filter((t) => t.tags.includes(tag));
    return items;
  }
 
  stats(): { total: number; done: number; pending: number } {
    const all = [...this.tasks.values()];
    const done = all.filter((t) => t.done).length;
    return { total: all.length, done, pending: all.length - done };
  }
}
 
// ===================== 4. 命令注册表 =====================
type Ctx = { store: TaskStore; bus: EventBus<AppEvents> };
 
type CommandSpec<A> = {
  name: string;
  usage: string;
  parse: (argv: readonly string[]) => Result<A>;
  run: (args: A, ctx: Ctx) => Result<string[]>;
};
 
type AnyCommand = {
  name: string;
  usage: string;
  exec: (argv: readonly string[], ctx: Ctx) => Result<string[]>;
};
 
function defineCommand<A>(spec: CommandSpec<A>): AnyCommand {
  return {
    name: spec.name,
    usage: spec.usage,
    exec: (argv, ctx) => {
      const parsed = spec.parse(argv);
      return parsed.ok ? spec.run(parsed.value, ctx) : parsed;
    },
  };
}
 
function render(t: Task): string {
  const box = t.done ? "[x]" : "[ ]";
  const tags = t.tags.length > 0 ? "  " + t.tags.map((x) => "#" + x).join(" ") : "";
  return box + " #" + t.id + " " + t.title + tags;
}
 
const commands: AnyCommand[] = [];
 
const addCmd = defineCommand<{ title: string; tags: string[] }>({
  name: "add",
  usage: "add 标题文字 [#标签...]     新增任务",
  parse: (argv) => {
    const tags = argv.filter((w) => w.startsWith("#")).map((w) => w.slice(1));
    const title = argv.filter((w) => !w.startsWith("#")).join(" ").trim();
    if (title === "") return err("add 需要一个标题");
    return ok({ title, tags });
  },
  run: (args, ctx) => {
    const r = ctx.store.add(args.title, args.tags);
    return r.ok ? ok(["已新增 " + render(r.value)]) : err(r.error);
  },
});
 
const doneCmd = defineCommand<{ id: number }>({
  name: "done",
  usage: "done 编号                  标记完成",
  parse: (argv) => {
    const raw = at(argv, 0);
    if (raw === undefined) return err("done 需要一个任务编号");
    const id = Number(raw);
    if (!Number.isInteger(id) || id <= 0) return err("非法编号: " + raw);
    return ok({ id });
  },
  run: (args, ctx) => {
    const r = ctx.store.complete(args.id);
    return r.ok ? ok(["已完成 " + render(r.value)]) : err(r.error);
  },
});
 
const rmCmd = defineCommand<{ id: number }>({
  name: "rm",
  usage: "rm 编号                    删除任务",
  parse: (argv) => {
    const raw = at(argv, 0);
    if (raw === undefined) return err("rm 需要一个任务编号");
    const id = Number(raw);
    if (!Number.isInteger(id) || id <= 0) return err("非法编号: " + raw);
    return ok({ id });
  },
  run: (args, ctx) => {
    const r = ctx.store.remove(args.id);
    return r.ok ? ok(["已删除 #" + r.value]) : err(r.error);
  },
});
 
const lsCmd = defineCommand<Filter>({
  name: "ls",
  usage: "ls [--done|--pending] [#标签]  列出任务",
  parse: (argv) => {
    const filter: Filter = {};
    for (const a of argv) {
      if (a === "--done") filter.done = true;
      else if (a === "--pending") filter.done = false;
      else if (a.startsWith("#")) filter.tag = a.slice(1);
      else return err("ls 不认识的参数: " + a);
    }
    return ok(filter);
  },
  run: (args, ctx) => {
    const items = ctx.store.list(args);
    return ok(items.length === 0 ? ["(没有匹配的任务)"] : items.map(render));
  },
});
 
const statsCmd = defineCommand<null>({
  name: "stats",
  usage: "stats                      查看统计",
  parse: () => ok(null),
  run: (_args, ctx) => {
    const s = ctx.store.stats();
    return ok(["共 " + s.total + " 条,已完成 " + s.done + " 条,待办 " + s.pending + " 条"]);
  },
});
 
const helpCmd = defineCommand<null>({
  name: "help",
  usage: "help                       显示帮助",
  parse: () => ok(null),
  run: () => ok(["可用命令:", ...commands.map((c) => "  " + c.usage)]),
});
 
commands.push(addCmd, doneCmd, rmCmd, lsCmd, statsCmd, helpCmd);
 
// ===================== 5. 调度器 =====================
class Cli {
  private readonly table = new Map<string, AnyCommand>();
 
  constructor(private readonly ctx: Ctx, cmds: readonly AnyCommand[]) {
    for (const c of cmds) this.table.set(c.name, c);
  }
 
  dispatch(line: string): void {
    const parts = line.trim().split(" ").filter((p) => p !== "");
    const name = at(parts, 0);
    if (name === undefined) return;
    const cmd = this.table.get(name);
    if (cmd === undefined) {
      console.log("  [错误] 未知命令 " + name + ",输入 help 查看用法");
      this.ctx.bus.emit("command:failed", { command: name, reason: "未知命令" });
      return;
    }
    const res = cmd.exec(parts.slice(1), this.ctx);
    if (res.ok) {
      for (const out of res.value) console.log("  " + out);
    } else {
      console.log("  [错误] " + res.error);
      this.ctx.bus.emit("command:failed", { command: name, reason: res.error });
    }
  }
}
 
// ===================== 6. 组装并运行 =====================
const bus = new EventBus<AppEvents>();
bus.on("task:added", (p) => console.log("  · 事件 task:added -> " + p.task.title));
bus.on("task:completed", (p) => console.log("  · 事件 task:completed -> " + p.task.title));
bus.on("task:removed", (p) => console.log("  · 事件 task:removed -> #" + p.id));
bus.on("command:failed", (p) => console.log("  · 事件 command:failed -> " + p.command + ": " + p.reason));
 
const store = new TaskStore(bus);
const cli = new Cli({ store, bus }, commands);
 
const script: string[] = [
  "add 写完 TypeScript 教程 #docs #ts",
  "add 准备周会材料 #work",
  "add 买咖啡豆",
  "ls",
  "done 2",
  "done 2",
  "rm 3",
  "rm 99",
  "ls --pending",
  "ls #ts",
  "stats",
  "fly",
  "help",
];
 
for (const line of script) {
  console.log("$ " + line);
  cli.dispatch(line);
}
console.log("脚本执行完毕");

4. 这套设计好在哪

设计点类型收益
事件映射表 + K extends keyof E事件名与载荷绑定,写错立即报错
内部回调存为 (p: never) => void存入零断言,取出的断言封装在内部
Result 而非异常调用方必须处理失败分支
defineCommand 闭包擦除 A每条命令参数类型精确,注册表接口统一
readonly 字段 + 不可变更新旧引用不会被意外修改
参数属性 private readonly bus依赖显式声明,便于替换与测试

5. 继续演进的方向

  1. 持久化:加一个 Storage 接口(load/save),内存实现和文件实现各写一份,用依赖注入切换。这一步会让你重新体会「面向接口编程」在 TS 里有多轻量。
  2. 中间件:给 dispatch 加一条洋葱模型的中间件链(日志、计时、权限),类型上用 (ctx, next) => Result<string[]> 建模。
  3. 命令参数的类型体操:用模板字面量类型从 usage 字符串里解析出参数名与类型,实现「写一遍用法字符串,自动得到参数类型」。
  4. 真实 CLI 入口:把 script 换成 process.argv.slice(2),加上 readline 做交互模式。
  5. 测试:用第 21 章的方法给 TaskStore 写单元测试,给 EventBus 写类型测试(验证 emit 的第二个参数类型确实被约束)。
🎯练习
  1. 给 EventBus 加一个 waitFor<K>(type: K): Promise<E[K]> 方法,返回一个在事件下次触发时 resolve 的 Promise,并处理好监听器清理。
  2. 新增一条 edit 编号 新标题 命令,要求 TaskStore 增加 rename 方法并发出 task:renamed 事件——注意你必须先在 AppEvents 里声明这个事件,否则编译不过,这正是映射表的价值。
  3. 把 Result 的 error 从 string 换成第 18 章的可辨识联合 AppFailure,并在 dispatch 里用 assertNever 做穷尽渲染。
  4. 给 Filter 加上 keyword?: string 支持标题模糊搜索,并补上对应的 ls --q 关键字 参数解析。

小结

  • 事件总线用「事件名 → 载荷」映射表 + keyof/索引访问,让 on 与 emit 双向类型安全
  • 内部存储用 (p: never) => void 擦除类型,利用参数逆变实现「存入无需断言」
  • 领域层用 Result 表达可预期失败,invariant 表达不该发生的 bug
  • defineCommand 用闭包把泛型参数 A 封存起来,实现「定义时精确、注册时统一」
  • 不可变更新 + readonly 让状态变化可控,为撤销、事件溯源留下空间
  • 到这里,TypeScript 课程全部结束。接下来最好的练习,就是把手上的一个 JS 项目改造成 TS 🎉