第一个 TypeScript 应用

要点

类型检查的即时收益是改变错误暴露的时机:拼错字段、传错类型、漏判空值,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。整条链路三步:

  1. 写类型:用 interface 描述数据长什么样
  2. 检查:tsc –noEmit(或编辑器红线)拿代码对照类型定义挑错——这一步不产出任何文件
  3. 运行: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 为直接运行推荐方案。安装命令不钉版本号,以官方文档为准。

暂无评论

发送评论 编辑评论


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