要点
类型检查的即时收益是改变错误暴露的时机:拼错字段、传错类型、漏判空值,JavaScript 里要等运行时甚至等用户才炸,TypeScript 里保存那一刻就标红。外部数据(JSON、命令行参数)先当 unknown 再验证收窄——这是第一课。
创建工程
环境按上一篇就位后,从零建这个 CLI 只要四条命令:
mkdir task-cli && cd task-cli
npm init -y
npm install typescript tsx
npx tsc --init
把 package.json 的 scripts 改成下面这样(type-check 与 start 分开,原因见常见问题),并加 "type": "module" 按 ESM 解析:
{
"type": "module",
"scripts": {
"type-check": "tsc --noEmit",
"start": "tsx src/main.ts"
}
}
建两个源文件:src/task.ts 定义数据形状与验证,src/main.ts 是入口。
核心概念:类型检查发生在什么时候
定义:tsc 的类型检查是编译期行为,运行时类型信息会被完全擦除。tsx 转译时把类型注解整体擦掉,剩下的就是普通 JavaScript。整条链路三步:
- 写类型:用 interface 描述数据长什么样
- 检查:tsc –noEmit(或编辑器红线)拿代码对照类型定义挑错——这一步不产出任何文件
- 运行:tsx 擦掉类型交给 Node 执行,运行时不存在 Task 这个类型
与 JavaScript 的关键差异只有一处:类型不改变程序的行为,只改变你知道真相的时间。同一处拼错,JS 里悄悄输出 undefined,TS 里编译期就把正确拼法递给你。
第一个应用:待办清单 CLI
先让类型说清数据形状,再写验证——JSON.parse 的返回值不可信,磁盘上的文件可能是任何东西:
// src/task.ts
export interface Task {
id: number;
title: string;
done: boolean;
}
// 入参用 unknown 而不是 Task:强迫自己先验证再使用
// 如果直接 as Task,坏数据会带着类型背书混进程序,炸在更远的地方
export function parseTask(raw: unknown): Task | null {
if (typeof raw !== "object" || raw === null) return null;
const t = raw as Record<string, unknown>; // 已确认是对象,收窄取字段
if (typeof t.id !== "number") return null;
if (typeof t.title !== "string") return null;
if (typeof t.done !== "boolean") return null;
return { id: t.id, title: t.title, done: t.done }; // 三项全过,才配叫 Task
}
// src/main.ts
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { parseTask, type Task } from "./task.js";
// Node ESM 风格:导入写 .js,tsc/tsx 会解析到 .ts 源文件
const FILE = "tasks.json";
function load(): Task[] {
if (!existsSync(FILE)) return [];
const raw: unknown = JSON.parse(readFileSync(FILE, "utf8"));
if (!Array.isArray(raw)) return [];
return raw.map(parseTask).filter((t): t is Task => t !== null); // 坏数据丢弃,程序不崩
}
function save(tasks: Task[]): void {
writeFileSync(FILE, JSON.stringify(tasks, null, 2), "utf8");
}
const [cmd, ...args] = process.argv.slice(2);
const tasks = load();
if (cmd === "add") {
const title = args.join(" ");
if (!title) {
console.error("用法: npm run start add 任务内容");
process.exit(1);
}
// 新 id 取当前最大 id 加一;空表从 1 开始
const id = tasks.length ? Math.max(...tasks.map((t) => t.id)) + 1 : 1;
tasks.push({ id, title, done: false });
save(tasks);
console.log(`已添加 #${id}: ${title}`);
} else if (cmd === "done") {
const id = Number(args[0]);
const task = tasks.find((t) => t.id === id);
if (!task) {
console.error(`找不到 #${id}`);
process.exit(1);
}
task.done = true; // 布尔字段,赋字符串会当场被拦(下面演示)
save(tasks);
console.log(`已完成 #${id}: ${task.title}`);
} else {
for (const t of tasks) {
console.log(`${t.done ? "[x]" : "[ ]"} #${t.id} ${t.title}`);
}
}
可观察结果:
npm run start add 买牛奶
1. 已添加 #1: 买牛奶
npm run start add 复盘这周的支出
1. 已添加 #2: 复盘这周的支出
npm run start done 1
1. 已完成 #1: 买牛奶
npm run start
1. [x] #1 买牛奶
1. [ ] #2 复盘这周的支出
同目录多出的 tasks.json 就是持久化,字段与 interface Task 一一对应——类型定义同时是数据格式的文档。
类型检查的即时收益:故意弄坏它
空口无凭,把两处常见手滑引进来,先跑类型检查,不运行:
# 手滑一:把 task.title 拼成 task.titel
1. 手滑二:把 task.done = true 写成 task.done = "yes"
npm run type-check
1. src/main.ts:44:34 - error TS2551: Property 'titel' does not exist on type 'Task'.
1. Did you mean 'title'?
1. src/main.ts:47:5 - error TS2322: Type 'string' is not assignable to type 'boolean'.
两处错误连正确拼法一起递过来,程序一行没运行。同样的手滑放在 JavaScript 项目里:
| 手滑 | JavaScript 里的下场 | TypeScript 里的下场 |
|---|---|---|
| title 拼成 titel | 输出 undefined,悄悄缺一块,可能数天后才发现 | 编译期 TS2551,附正确拼法 |
| done 赋成 “yes” | 存进 JSON,下次启动行为异常且难定位 | 编译期 TS2322,指到具体行 |
| find 没找到还继续用 | 运行时 TypeError: Cannot read properties of undefined | strict 下提示先判空,收窄后再用 |
这就是”即时”的含义:暴露点从运行时挪到保存时,从用户手里挪到你手里。修掉手滑,type-check 归零,再跑四个命令确认行为没变。
最佳实践
第一个应用只需要一个能解释清楚的闭环:数据从哪来(JSON/argv)、类型在哪定义(task.ts)、错误在哪暴露(type-check)。外部数据一律先 unknown 再验证收窄,验证函数就是类型系统与真实世界之间的海关。类型定义改了(比如把 done 改成联合类型),编译器会把所有受影响的行标出来——改定义然后读报错,是重构的正确顺序。也要清楚边界:类型检查拦得住拼错与传错类型,拦不住逻辑写反——行为正确性仍要靠测试与运行验证。
常见问题
Q1: tsx 已经跑通了,还要跑 tsc –noEmit 吗
要。tsx 只转译不检查类型——上面两处手滑在 tsx 下照样跑得欢,直到运行到那一行。运行靠 tsx,类型靠 tsc,两条命令职责不同,都要进工作流。
Q2: JSON.parse 为什么不直接给我 Task 数组
类型只在编译期存在,JSON 来自磁盘是运行时数据,编译器无从得知里面是什么,所以只能当 unknown、由验证函数收窄。反模式:as Task 一把梭。as 不做任何检查,只是让编译器闭嘴——坏数据带着类型背书在程序深处爆炸,比没有类型更迷惑。
Q3: strict 报错太多,从哪开始修
从第一行报错开始逐条修,绝大多数是空值收窄:报”可能在为 undefined”处补判空或默认值即可。
练习与实践
练习一:加一个 delete 命令
任务要求:仿照 done 分支实现 delete,按 id 删除任务并保存;对不存在的 id 给出与 done 一致的报错。评估标准:tasks.json 同步减少且 type-check 零错误。
练习二:制造三个类型错误并读懂报错
任务要求:分别引入拼错字段、布尔字段赋字符串、给 find 传字符串 id 三处错误;每次只改一处,记录 tsc 的报错码。提示:报错码是查文档的最快入口。
练习三:改类型定义,看波及面
任务要求:把 done 从 boolean 改成 "todo" | "done" | "dropped" 联合类型,跑 type-check,逐个修复被标红的行。观察重点:改一处定义,编译器替你找出所有要跟着改的地方——这是类型系统在重构时的复利。
延伸阅读
环境细节回看《TypeScript 环境搭建与工具链》;这个待办雏形将在《用 TypeScript 写一个命令行待办工具》里按实战标准工程化——两篇分工:本章只管类型检查的即时收益;类型放进框架里怎么用,看《Next.js 与 TypeScript》。
版本核实(2025-02-08 撰写时检索):文中工具链现状与上一篇一致——TypeScript 稳定版 5.7.x、Node 活跃 LTS 22、tsx 为直接运行推荐方案。安装命令不钉版本号,以官方文档为准。