一、先说清楚要做什么
用 TypeScript 写一个命令行待办工具:能添加、列出、完成、删除待办,数据落在本地 JSON 文件。三句话讲完。但它是个绝佳的 TS 练手项目——类型从边界(argv、文件内容)流进领域模型,再流出边界,“外部数据不可信”这个约束会让每个设计决策都变重。
验收标准不是”能跑”,是这张表:
| 验收项 | 目标 | 怎么测 |
|---|---|---|
| 类型检查零错误 | tsc –noEmit 退出码 0 | npm run check |
| 增删查改闭环 | add → list → done → rm 全链路 | 下文命令序列逐条执行 |
| 脏数据不带毒运行 | 损坏的 JSON 给出可读错误而非静默错乱 | 手工写坏文件再 list,看退出码 |
| 失败路径退出码 | 错误命令退出非 0,脚本可组合 | npx tsx todo.ts 未知命令; echo $? |
前置:装好 Node.js 与编辑器(没配过的看TypeScript 环境搭建);判别联合、z.infer 这些概念在语法要点篇有展开。
二、整体设计:一条数据流
process.argv ──util.parseArgs──> 命令 + 参数
│
▼
todos.json ──readFileSync──> JSON.parse
│ (产物按 unknown 处理)
▼
zod 校验 ──通过──> Todo[](领域类型,此后全静态)
│
▼
命令处理 ──写回──> 临时文件 + rename(原子替换)
│
▼
Result 判别联合 ──> 输出 + 退出码
设计原则一句话:类型系统的保护从 zod 校验通过那一刻开始——通过之前的一切(argv、文件内容)都是不可信输入。
三、技术决策链
决策一:怎么运行 TypeScript
| 方案 | 做法 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| tsx | npx tsx todo.ts | esbuild 转译,全语法支持、watch 模式 | 多一个依赖,只转译不检查类型,需配 tsc –noEmit | 本项目选它:纯脚本、零配置上手 |
| tsc 构建 | 编译到 dist/ 再运行产物 | 发布到 npm 给别人装的标准路径 | 多一步构建,改代码要重编 | 分发给用户的正式 CLI 包 |
| ts-node | — | 历史上流行 | 处于维护状态,ESM 兼容问题多 | 不选,新项目别再上车 |
本项目的组合是双轨:tsx 直接跑(快)+ tsc –noEmit 做类型检查(准)。tsx 转译时只擦类型不做检查,”跑得起来”与”类型没错”是两件事,所以两条命令都保留,各管一头。
决策二:参数解析
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 手切 process.argv | 零依赖 | 下标管理易错,选项语法全手写 | 只有一个固定位置参数时 |
| util.parseArgs(Node 18.3+ 内置) | 零依赖,strict 模式对未知选项直接报错 | 复杂多级子命令表达力弱 | 本项目选它:四个子命令、几乎没有选项 |
| commander(当前 12.x) | 帮助文本自动生成、子命令体系成熟 | 引第三方依赖 | 选项多、要发布的正式 CLI |
反模式:手写 argv[2]、argv[3] 下标取值。参数一旦带引号拼接(todo add 买 牛奶),下标语义立刻碎掉——按空格 join 回文本才是符合直觉的入参。
决策三:持久化
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| JSON 单文件(node:fs) | 人类可读、git diff 友好、零依赖 | 每次全量读写;无并发保护 | 本项目选它:单人使用的百条级数据 |
| SQLite(better-sqlite3) | 事务、查询、量级 | 原生模块依赖,安装平台敏感 | 万条以上、需要条件查询 |
| 不持久化 | 最简单 | 重启即丢 | 待办工具不适用 |
为什么选 node:fs 而不引存储库:待办工具的读写模式是”启动→全量载入→单命令→全量写回”,一个 JSON 文件与一个 for 循环就是全部所需,任何库在此都是纯开销。
决策四:边界数据校验
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 手写 if 检查 | 零依赖 | 字段一多必漏;校验逻辑与类型声明两张皮 | 一两个字段 |
| 类型谓词(x is T) | 零依赖且能收窄 | 维护成本随字段数线性涨 | 不引依赖的库代码 |
| zod(当前 v3) | schema 一份同时产出运行时校验与静态类型(z.infer) | 引依赖 | 本项目选它:边界就在文件读写一处,收益覆盖成本 |
四、核心实现
初始化:npm init -y 后在 package.json 加 "type": "module"(.ts 的模块判定与 .js 同规则),然后 npm install zod、npm install -D typescript tsx——版本由 npm 解析写入,本文不钉版本号。tsconfig 只做检查不产出:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"module": "nodenext",
"moduleResolution": "nodenext",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noEmit": true
}
}
完整代码 todo.ts(每个设计点都有注释解释为什么):
import { parseArgs } from "node:util";
import { randomUUID } from "node:crypto"; // 内置,不为一个 UUID 引第三方库
import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
import { z } from "zod";
// ---- 领域模型:schema 是唯一事实来源,类型从 schema 推导 ----
const TodoSchema = z.object({
id: z.string(),
text: z.string().min(1),
done: z.boolean(),
});
const TodoListSchema = z.array(TodoSchema);
type Todo = z.infer<typeof TodoSchema>; // 校验与类型永远同步,改一处自动跟随
const FILE = "todos.json";
// ---- 读:外部数据按 unknown 处理,校验通过才升格为 Todo[] ----
function load(): Todo[] {
if (!existsSync(FILE)) return [];
const raw: unknown = JSON.parse(readFileSync(FILE, "utf8"));
// JSON.parse 返回 any——立刻收进 unknown,禁止 any 顺着数据流传染
return TodoListSchema.parse(raw); // 脏数据在这里抛 ZodError,拒绝带毒运行
}
// ---- 写:临时文件 + rename,同文件系统上 rename 是原子的 ----
function save(todos: Todo[]): void {
const tmp = `${FILE}.tmp`;
writeFileSync(tmp, JSON.stringify(todos, null, 2), "utf8");
renameSync(tmp, FILE); // 直接覆盖写目标文件:进程中途死掉会留半个 JSON
}
// ---- 命令结果用判别联合:调用处对 ok 收窄,不可能出现"成功但没消息" ----
type Result =
| { ok: true; message: string }
| { ok: false; message: string };
function run(argv: string[]): Result {
// strict 模式默认开启:传了不认识的选项会抛错,早失败好过静默忽略
const { positionals } = parseArgs({ allowPositionals: true, args: argv });
const [cmd, ...rest] = positionals;
switch (cmd) {
case "add": {
const text = rest.join(" ").trim(); // 带空格的文本按空格拼回,不碰下标
if (!text) return { ok: false, message: "用法: npx tsx todo.ts add 文本" };
const todos = load();
todos.push({ id: randomUUID(), text, done: false });
save(todos);
return { ok: true, message: `已添加: ${text}` };
}
case "list": {
const todos = load();
if (todos.length === 0) return { ok: true, message: "(空列表)" };
return {
ok: true,
message: todos
.map((t, i) => `[${t.done ? "x" : " "}] ${i + 1}. ${t.text}`)
.join("n"),
};
}
case "done":
case "rm": {
const todos = load();
const idx = Number(rest[0]) - 1;
// noUncheckedIndexedAccess 下 todos[idx] 是 Todo|undefined:必须先收窄
if (!todos[idx]) return { ok: false, message: `没有第 ${rest[0] ?? "?"} 条` };
if (cmd === "done") {
todos[idx].done = true;
save(todos);
return { ok: true, message: `完成: ${todos[idx].text}` };
}
const [removed] = todos.splice(idx, 1);
save(todos);
return { ok: true, message: `已删除: ${removed.text}` };
}
default:
return { ok: false, message: `未知命令: ${cmd ?? "(无)"},可用: add/list/done/rm` };
}
}
// ---- 入口:任何逃逸的异常(脏文件、磁盘满)在这里变成可读输出 ----
try {
const result = run(process.argv.slice(2));
console.log(result.message);
process.exitCode = result.ok ? 0 : 1; // 退出码也是接口,供脚本组合
} catch (e) {
console.error(`数据文件损坏或不可读: ${e instanceof Error ? e.message : String(e)}`);
process.exitCode = 2;
}
五、验证
类型检查与功能闭环分开验,先类型后行为:
npm run check # tsc --noEmit,退出码 0 才继续
npx tsx todo.ts add 买牛奶 # 已添加: 买牛奶
npx tsx todo.ts add 写周报 # 已添加: 写周报
npx tsx todo.ts list # [ ] 1. 买牛奶
1. [ ] 2. 写周报
npx tsx todo.ts done 1 # 完成: 买牛奶
npx tsx todo.ts rm 2 # 已删除: 写周报
1. 脏数据防御:手工写坏文件,程序应拒绝带毒运行
echo 'not json' > todos.json
npx tsx todo.ts list # 可读报错,退出码 2
echo $? # 2
| 检查点 | 预期 | 不达标的常见原因 |
|---|---|---|
| tsc –noEmit | 退出码 0 | 导入漏写 .js 后缀、exactOptionalPropertyTypes 类新开关 |
| list 输出格式 | 序号与完成态对应 | rest.join 拼接时机错了 |
| 写坏文件后运行 | 可读报错 + 退出码 2 | JSON.parse 结果被 as 强转,脏数据穿透了校验 |
| todos.json.tmp 残留 | 正常流程无残留 | rename 失败(跨文件系统),tmp 与目标要同目录 |
性能只给定性结论:单文件、百条级数据,全量读写与进程启动都在毫秒级体感,选型不受影响;具体毫秒数本文不测不编。真到了万条数据,瓶颈在”每命令全量序列化”,那时换 SQLite 而不是优化 JSON。
六、总结与边界
- 类型保护是有起点的:zod 校验通过之前,一切输入都是 unknown;起点越靠边界,后面的代码越自由。
- 运行与检查双轨(tsx 直跑 + tsc –noEmit)是 2025 年 TS 生态最轻的工作流,转译与检查各司其职。
- 原子写三行代码(tmp + rename)换”任何时刻断电文件都完整”,这笔账永远划算。
- 退出码是 CLI 的类型系统:0 成功、1 用户错、2 数据错,脚本才敢组合你。
边界也要说清:JSON 单文件方案不适合多进程并发写(两个终端同时 todo add 会互相覆盖,无锁)、不适合大体量数据。这不是优化问题,是模型上限——到那一步直接换 SQLite,不要给 JSON 打补丁。
下一步:整条路线的收束在TypeScript 学习路线;把这个工具从单文件拆成多模块(含 import type 的用法),概念都在参考字典的模块与配置组。
版本说明:截至写作(2025-03-01),Node.js 22 为当前活跃 LTS;Node 运行时尚不能直接执行 .ts 文件,直接运行依赖 tsx 这类转译工具;zod 当前稳定大版本为 v3;commander 当前 12.x;npm latest 的 TypeScript 为 5.7.x(2024-11 发布)。具体以官方文档为准。