用 TypeScript 写一个命令行待办工具

一、先说清楚要做什么

用 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 发布)。具体以官方文档为准。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇