项目实战:类型安全的任务 CLI
学完二十一章语法,最后我们把它们串成一个完整的东西:一个任务管理 CLI 的内核。它不依赖任何第三方包,全部用纯 TypeScript 实现,包含三个可以直接搬进真实项目的组件:
- 泛型事件总线:用一张「事件名 → 载荷类型」的映射表,让
emit和on双向类型安全。 - Result 化的领域存储:所有可预期的失败都编码在返回值里,调用方无法忽略。
- 命令注册表:每条命令自带参数解析器,参数类型在注册时被推导出来,调度器却只面对统一接口。
用到的知识点:泛型约束、映射类型、索引访问、可辨识联合、参数属性、类型擦除封装。建议边读边在 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。这样任何持有旧引用的代码都不会看到意外变化,也让后续加撤销、加事件溯源变得简单。
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,代码会被 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 数组里的命令来试验。
// ===================== 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. 继续演进的方向
- 持久化:加一个
Storage接口(load/save),内存实现和文件实现各写一份,用依赖注入切换。这一步会让你重新体会「面向接口编程」在 TS 里有多轻量。 - 中间件:给
dispatch加一条洋葱模型的中间件链(日志、计时、权限),类型上用(ctx, next) => Result<string[]>建模。 - 命令参数的类型体操:用模板字面量类型从
usage字符串里解析出参数名与类型,实现「写一遍用法字符串,自动得到参数类型」。 - 真实 CLI 入口:把
script换成process.argv.slice(2),加上readline做交互模式。 - 测试:用第 21 章的方法给
TaskStore写单元测试,给EventBus写类型测试(验证emit的第二个参数类型确实被约束)。
- 给
EventBus加一个waitFor<K>(type: K): Promise<E[K]>方法,返回一个在事件下次触发时 resolve 的 Promise,并处理好监听器清理。 - 新增一条
edit 编号 新标题命令,要求TaskStore增加rename方法并发出task:renamed事件——注意你必须先在AppEvents里声明这个事件,否则编译不过,这正是映射表的价值。 - 把
Result的error从string换成第 18 章的可辨识联合AppFailure,并在dispatch里用assertNever做穷尽渲染。 - 给
Filter加上keyword?: string支持标题模糊搜索,并补上对应的ls --q 关键字参数解析。
小结
- 事件总线用「事件名 → 载荷」映射表 +
keyof/索引访问,让on与emit双向类型安全 - 内部存储用
(p: never) => void擦除类型,利用参数逆变实现「存入无需断言」 - 领域层用
Result表达可预期失败,invariant表达不该发生的 bug defineCommand用闭包把泛型参数A封存起来,实现「定义时精确、注册时统一」- 不可变更新 +
readonly让状态变化可控,为撤销、事件溯源留下空间 - 到这里,TypeScript 课程全部结束。接下来最好的练习,就是把手上的一个 JS 项目改造成 TS 🎉