TS TypeScript 入门手册
目录
TypeScript from zero · 零基础路线

TypeScript 从第一行开始

先用 TypeScript 写出第一行代码,逐个认识符号、变量、条件、循环、函数和对象。掌握这些写法之后,再用 Python 对照理解语言差异,最后动手跑通一个 Node.js 项目。

开始第一课 ↓
阅读路线 / 21 章
  1. 基础语法 · 从一行输出到小程序
  2. Python 对照 · 用熟悉的语言理解差异
  3. 类型推理 · 联合、收窄、泛型与校验
  4. 项目执行 · 模块、异步、构建与 CLI

先把一小段 TS 读顺

第 01 至 06 章只写 TypeScript。读每段代码时,先按顺序读出它做了什么,再猜会显示什么,最后看代码框下方的结果。每章都有一道可以先猜再展开的练习。

从第 07 章开始,再拿 Python 来对照已经学过的概念。示例按段独立阅读,跨文件示例会注明文件名。跟着动手时,可以先在本地跑第一章的 hello.ts。

CHAPTER 01

写下第一行 TypeScript

先写出一条能运行的语句,再认识这一行里的点、括号、引号和分号。

TypeScript · hello.ts
console.log("你好,TypeScript!");
输出 你好,TypeScript!
  1. console 是运行环境已经提供的一个对象,可以先把它理解为一组和控制台有关的工具。
  2. 点 . 用来取出对象上的成员。console.log 取出负责打印的函数。
  3. (...) 调用这个函数,括号里是交给它的内容。"你好,TypeScript!" 是一个字符串,引号标记文字的起止位置。
  4. 分号 ; 在这里结束这条语句。入门示例统一写上分号。语法符号使用半角字符,字符串里的文字可以使用中文标点。

把代码存进文件,然后在终端运行

以下动手示例使用 Node.js 24。打开 PowerShell,输入 node --version 确认环境。用文本编辑器新建文件,存为 hello.ts。在该文件所在的目录打开终端,再输入下方命令。上一框是文件内容,下一框是终端命令。

PowerShell · 运行文件
node hello.ts
终端输出 你好,TypeScript!

每个示例可以单独保存运行。练习时可以反复替换 hello.ts 的内容,保存后再次执行命令。当前先观察程序做了什么,后面再讲 Node 如何处理 .ts 和怎样检查类型。

顺序、注释与表达式

TypeScript · 三条打印语句
// 这一行写给读代码的人
console.log("2 + 3");
console.log(2 + 3);
console.log("总计", 2 + 3);
输出 2 + 3 5 总计 5

程序从上到下执行。// 到行末是注释。第二行代码中的 "2 + 3" 整体是一段文字;下一行中的 2 + 3 会先计算,得到 5 再交给 log。这里能产生值的 2 + 3 叫表达式。逗号分隔多个参数,log 在同一行显示它们。

自测 · console.log("10", 10 + 1); 会显示什么?

显示 10 11。第一个参数是文本,第二个参数是加法的结果。直接打印时,字符串外的引号一般不会出现在终端里。

CHAPTER 02

声明变量,认识三种基本类型

用名字保存值。先学会辨认声明、类型注解、赋值和取值。

TypeScript · 逐字读一条声明
const title: string = "整理笔记";
let count: number = 1;
const ready: boolean = true;

count = count + 2;
console.log(title, count, ready);
输出 整理笔记 3 true
部分在第一行里的意思
const声明一个初始化后不能重新赋值的名字。需要重新赋值时用 let。
title变量名。后面直接写 title 就读取它保存的值。
: string类型注解,说明这个名字应保存字符串。数值用 number,真假值用 boolean。
= "整理笔记"初始化,给变量第一个值。后面的 count = count + 2 先算等号右边,再把新值写回左边。

初始值已经给出类型线索

TypeScript · 让检查器推断类型
let score = 10;
score = score + 5;
const label = "得分";
console.log(label, score);
console.log(typeof score, typeof label, typeof false);
输出 得分 15 number string boolean

从 10 就能推断 score 应该装数值。这里的 typeof 在运行时查看值的类别。若加一行 score = "十五";,TypeScript 类型检查会给出 TS2322,因为字符串不能赋给 number。第 20 章会演示如何独立运行检查。直接运行 node hello.ts 主要用于观察执行行为。

TypeScript · 把值拼进一行文字
const name = "小林";
const done = 2;
console.log(`${name}已经完成 ${done + 1} 项。`);
输出 小林已经完成 3 项。

模板字符串的边界是反引号 `。遇到 ${...},先计算里面的表达式,再把结果放进文字。常见错误是把反引号换成普通引号,那样会原样打印 ${name}。

自测 · 要每天增加 done,该用 const 还是 let?

用 let done = 2;。接下来执行 done = done + 1;,再打印 done,输出是 3。

CHAPTER 03

写条件,让程序选择一条路

区分赋值和比较,读懂括号中的条件和花括号中的语句。

TypeScript · if / else if / else
const score = 72;

if (score >= 90) {
  console.log("优秀");
} else if (score >= 60) {
  console.log("通过");
} else {
  console.log("待练习");
}
console.log("评估结束");
输出 通过 评估结束

if (条件) { ... } 中,圆括号包住要判断的表达式,花括号包住满足条件时执行的语句。这里先检查 72 >= 90,得到 false。继续检查 72 >= 60,得到 true,于是打印「通过」。这一组分支只执行其中一块。最后一行在花括号外,所以无论选中哪条分支,都会打印「评估结束」。

TypeScript · 比较与组合
const mode: string = "preview";
const count = 2;
console.log(count === 2);
console.log(count !== 2);
console.log(mode === "preview" && count > 0);
console.log(mode === "run" || count > 0);
输出 true false true true

= 写入值,=== 比较是否相等,!== 比较是否不等。&& 用来组合「两个条件都成立」,|| 表示「至少一个条件成立」,! 反转一个条件。刚开始可以用括号明确每个子条件的边界。

自测 · 把第一段的 score 改为 90,会输出什么?

先输出「优秀」,再输出「评估结束」。第一条条件已经成立,后面的 else if 不再进入。

CHAPTER 04

用数组装多个值,再逐个处理

认识方括号的两种写法,学会用循环完成一次累计。

TypeScript · 创建、读取和追加
const tasks: string[] = ["读资料", "写笔记"];
console.log(tasks[0]);
console.log(tasks[1]);
tasks.push("做练习");
console.log(tasks.length);
输出 读资料 写笔记 3

string[] 在类型位置读作「字符串数组」。等号右边的 ["读资料", "写笔记"] 创建一个数组。变量后面的 [0] 是索引,取第一项。.push(...) 调用数组的方法追加一项,.length 读取长度,所以后面没有圆括号。const tasks 固定这个名字所指向的数组,仍可以往数组里加元素。

TypeScript · for...of 循环
const costs = [2, 4, 3];
let total = 0;
for (const cost of costs) {
  total = total + cost;
  console.log("当前总计", total);
}
console.log("最终总计", total);
输出 当前总计 2 当前总计 6 当前总计 9 最终总计 9

for (const cost of costs) 读作「从 costs 中依次取一个值,这一次叫它 cost」。花括号内的代码重复执行。total 放在循环外,只初始化一次。第一轮算 0 + 2,第二轮接着算 2 + 4,第三轮算 6 + 3。若把 let total = 0; 挪进循环,每轮都会从零重新开始。

需要索引时,用三段式 for

TypeScript · 用索引给任务编号
const tasks = ["读资料", "写笔记"];
for (let i = 0; i < tasks.length; i = i + 1) {
  console.log(i + 1, tasks[i]);
}
输出 1 读资料 2 写笔记

圆括号里的三个部分用分号隔开。let i = 0 只执行一次。每轮开始前检查 i < tasks.length。如果条件成立,执行花括号里的代码,再执行 i = i + 1,回到条件检查。索引从零开始,给人看的编号用 i + 1。

只要条件成立就重复,用 while

TypeScript · 倒数
let remaining = 3;
while (remaining > 0) {
  console.log(remaining);
  remaining = remaining - 1;
}
console.log("开始");
输出 3 2 1 开始

while 在每一轮前判断条件。这里每轮都减少 remaining,到零时退出。循环里遇到 break 会立即离开循环,遇到 continue 会跳过当前轮余下的语句。写 while 时留意每条分支是否都能让循环最终结束。

自测 · costs 如果是 [],还有哪一行输出?

循环体执行零次,最终输出 最终总计 0。动手时可以写成 const costs: number[] = [];,明确空数组今后应保存数值。

CHAPTER 05

写一个函数,给它参数并接住返回值

把声明和调用分开看,再识别项目里常见的箭头写法。

TypeScript · function 声明和两次调用
function add(a: number, b: number): number {
  return a + b;
}

const sum = add(2, 3);
console.log(sum);
console.log(add(sum, 1));
输出 5 6
  1. function add 定义一个名叫 add 的函数。程序随后遇到 add(2, 3) 才会执行函数体。
  2. (a: number, b: number) 声明两个参数及各自的类型。调用 add(2, 3) 时,a 得到 2,b 得到 3。
  3. 右括号后的 : number 是返回类型。return a + b; 结束本次调用,把计算结果交回调用处。
  4. const sum = add(2, 3); 把返回的值保存到 sum。下面的 console.log(sum) 才把它显示出来。

箭头函数的两种函数体

TypeScript · 表达式体和花括号块
const twice = (n: number) => n * 2;
const triple = (n: number) => {
  return n * 3;
};
console.log(twice(4), triple(4));
输出 8 12

=> 左边是参数,右边是函数体。只有一个表达式时,它的结果自动返回。右边若是花括号语句块,需要用 return 交回结果。第 11 章会进一步讲回调和闭包,初学时先练习从调用找到函数定义,再顺着 return 找到返回值。

自测 · console.log(add(3, add(1, 2))); 会输出什么?

输出 6。先执行内层的 add(1, 2),得到 3;外层实际执行 add(3, 3)。

CHAPTER 06

用对象把相关值放在一起

从属性读写过渡到对象类型,然后串起前六章的写法。

TypeScript · 对象与属性
const task = { title: "整理笔记", done: false };
console.log(task.title, task.done);
task.done = true;
console.log(task.title, task.done);
输出 整理笔记 false 整理笔记 true

等号右侧的 { ... } 创建一个对象。title: "整理笔记" 是一个属性,冒号左边是键,右边是值。属性用逗号分隔。task.title 读取 title,task.done = true; 修改 done。这里的花括号是在表达式中构造值,条件或函数中的花括号则包住一组语句。

把对象的形状写成类型

TypeScript · 类型、函数、数组和循环放在一起
type Task = {
  title: string;
  done: boolean;
};

function describe(task: Task): string {
  if (task.done) {
    return `已完成:${task.title}`;
  }
  return `待处理:${task.title}`;
}

const tasks: Task[] = [
  { title: "读资料", done: true },
  { title: "做练习", done: false },
];

for (const task of tasks) {
  console.log(describe(task));
}
输出 已完成:读资料 待处理:做练习
  1. type Task = { ... } 给一份类型起名字,描述有哪些属性、每个属性接受什么样的值。这里的 title: string 写的是字段类型。
  2. describe(task: Task) 的参数需要符合这份类型。函数先看 done,再返回一条文字。命中第一个 return 时,函数立即结束。
  3. Task[] 表示数组中每一项都是符合 Task 形状的对象。循环把它们逐个交给 describe,再打印返回值。
自测 · 把第二项的 done 改为 true,再添加第三项

第二行会变成 已完成:做练习。如果第三项是 { title: "复盘", done: false },就会再打印一行 待处理:复盘。

基础练习 · 先试着独立声明一个任务数组,写一个函数返回任务标题,用 for...of 把它们打印出来。能读写这些语句后,进入第 07 章,把刚学的语法和熟悉的 Python 行为放在一起比较。

CHAPTER 07

一份代码,两条时间线

看懂 .ts 文件怎样变成可执行行为,以及为什么类型正确和程序正常是两回事。

① 编写 .tsJavaScript 表达式 + 类型语法
② TypeScript 检查分析声明、调用关系和控制流
③ 生成 .js擦除类型,按配置转换语法
④ Node.js 执行解析模块、求值、调用宿主 API
图 01 · 第 21 章采用的路线。②可以单独运行,转译工具也可以单独完成③。
Python · print 和注解
def double(n: int) -> int:
    return n * 2

print(double(4))
print(double("4"))
Python 输出 8 44
TypeScript · 相同业务
function double(n: number): number {
  return n * 2;
}
console.log(double(4));
console.log(double("4"));
tsc 诊断与强行运行 JS 时的结果 TS2345: string 不能传给 number 参数 8 8

这里有两个变化。Python 的类型注解通常交给额外工具检查,Python 的字符串可以乘整数做重复。TypeScript 检查器会拒绝第二次调用;一旦对应 JavaScript 真的运行,JS 的 * 会把数字字符串转成数值。静态诊断与运算符语义各有自己的规则。

实验 · 同一函数经过类型擦除

type Count = number; const count: Count = 4; console.log(count * 2);

运行产物会打印 8。上图的②可以发现错误;noEmitOnError 决定发现错误时是否停止输出新产物。

把浏览器和 Node.js 理解成两种宿主。浏览器提供 DOM,Node 提供文件系统和进程 API。两者都能执行 JavaScript,所提供的 API 不同。TypeScript 的类型注解和 .d.ts 声明可描述这些 API,实际实现由宿主或依赖提供。

回到第一章的运行命令

第 01 章用 node hello.ts 直接启动代码。Node.js 24 可以擦除该示例的类型语法,再运行留下的 JavaScript。这条路径主要用于观察执行行为。到第 21 章再安装 tsc,把类型检查、生成文件和执行产物接起来。

深入一层 · Node 开始执行后发生什么?

加载器解析入口与依赖。对 ESM 来说,静态导入关系会在模块求值前建立。Node 使用的 V8 引擎解析 JavaScript、执行代码,并可根据运行反馈优化热点代码。函数调用创建执行上下文,调用结束后相应的栈帧退出。数组和对象通常通过引用共享,闭包引用的值也可在调用返回后继续存活。垃圾回收器根据可达性回收内存,所以函数返回并不意味着它创建的每个对象都会立刻消失。

例如第 11 章的 counter() 返回一个函数。只要返回的函数还可达,它引用的计数状态就能继续被读写。到了第 18 章,await 挂起后,恢复执行所需的状态也会跨调度保留。这里用调用栈和引用关系理解程序行为,V8 的实际存储位置可能受优化影响。

自测 · 能否用类型决定运行时分支?

类型别名 type Count = number 在产物里消失,无法写 x instanceof Count。可以执行 typeof x === "number",因为 typeof 读取的是运行时值。

CHAPTER 08

变量、值与作用域

把 Python 名称绑定的直觉迁移到 let、const、块作用域和 JS 基本类型。

Python · 绑定与共享对象
items = ["read"]
alias = items
alias.append("write")
print(items)
if True:
    status = "ready"
print(status)
输出 ['read', 'write'] ready
TypeScript · const 与块
const items = ["read"];
const alias = items;
alias.push("write");
console.log(JSON.stringify(items));
if (true) {
  const status = "ready";
  console.log(status);
}
输出 ["read","write"] ready

const 固定变量的绑定,数组、对象内部仍可修改。let 允许重新赋值。let 和 const 属于最近的一层块,如果把示例里的 console.log(status) 移到块外,就无法访问这个局部变量。它们从进入作用域到初始化之间处于暂时性死区,提前读取会触发 ReferenceError。旧代码里的 var 通常是函数级作用域,声明前读到的可能是 undefined。

Python 直觉TypeScript 写法运行语义
str / f-stringstring / `Hi ${name}`反引号支持插值和多行。JS 字符串以 UTF-16 码元计长度。
int、floatnumber / bigintnumber 是双精度浮点数,整数安全范围有限。bigint 用 42n,可表示大整数,算术通常要保持两侧同种类型。
boolboolean字面量 true、false,小写。
Nonenull、undefined缺失属性、未传入的参数常得到 undefined,null 常表示协议显式给出的空值。
唯一哨兵 object()Symbol("tag")每次调用 Symbol() 得到新的身份,可作为对象的属性键。
TypeScript · 数值与字符串
console.log(5 / 2, Math.floor(-5 / 2));
console.log(-5 % 2);
console.log(Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2);
console.log(String(9007199254740993n));
console.log("🚀".length, [..."🚀"].length);
console.log(Number("oops"), Number.isNaN(Number("oops")));
输出 2.5 -3 -1 true 9007199254740993 2 1 NaN true

JS 的 % 求余跟随被除数的符号,Python 的整数 % 跟随除数的符号。Math.trunc 朝零截断,Math.floor 朝负无穷取整。[...s] 按 Unicode 码点迭代,组合字符与复杂 emoji 仍可能跨多个码点。

TypeScript · 注解和推断
let retries = 3;              // 推断为 number
const mode = "dry-run";       // 推断为字面量 "dry-run"
let stage: "new" | "done" = "new";
stage = "done";
console.log(retries, mode, stage);
输出 3 dry-run done

给局部变量一个初始值往往已经足够。显式注解更适合对外边界、空集合的元素、稍后赋值的变量,以及需要限制取值的字段。

CHAPTER 09

控制流、比较与空值

用代码看清真假、相等、默认值以及循环的边界。

Python · 空容器与等值
print(bool([]), bool({}))
print([1] == [1])
print("" or "fallback")
for index, item in enumerate(["a", "b"]):
    print(index, item)
输出 False False True fallback 0 a 1 b
TypeScript · 对象为真、按身份比较
const first = [1];
const second = [1];
console.log(Boolean([]), Boolean({}));
console.log(first === second);
for (const [index, item] of ["a", "b"].entries()) {
  console.log(index, item);
}
输出 true true false 0 a 1 b

条件使用 if (条件) { ... } else if (...) { ... } else { ... }。常见 falsy 值有 false、0、0n、""、NaN、null、undefined。判空数组用 items.length === 0,判空对象可用 Object.keys(obj).length === 0 检查可枚举的自有字符串键。=== 不做跨类型强制转换,对象则按身份比较。== 有转换规则,阅读时留意 x == null 常被用来同时匹配 null 与 undefined。

TypeScript · 三种默认值行为
const configured: { retries?: number; name?: string | null } = {
  retries: 0,
  name: "",
};
console.log(configured.retries || 3);
console.log(configured.retries ?? 3);
console.log(JSON.stringify(configured.name?.trim() ?? "anonymous"));
console.log(({ name: null }).name ?? "anonymous");
const { retries = 5 } = configured;
console.log(retries);
输出 3 0 "" anonymous 0
  • a || b 在 a 为 falsy 时求值 b。a && b 在 a 为 truthy 时求值 b。它们返回操作数。
  • a ?? b 在 a 是 null 或 undefined 时求值 b。
  • a?.b、a?.[key]、fn?.() 分别对左侧的对象或函数进行空值短路。对多层可空属性逐层写 user?.profile?.name。
  • 默认参数、解构默认值只在值是 undefined 时启用。允许传入 null 时,它会原样保留。
TypeScript · 值、键与分支
const names = ["a", "b"];
for (const value of names) console.log(value);
for (const key in names) console.log(typeof key, key);
const count = names.length;
console.log(count > 0 ? "work" : "idle");
输出 a b string 0 string 1 work

for...of 迭代可迭代值。for...in 遍历对象可枚举的字符串属性名,可能包含原型上的属性;普通对象的自有键值对用 Object.entries(obj)。switch 匹配后会继续执行后续分支,除非遇到 break、return 或 throw。

自测 · 要保留重试次数 0,该选哪个运算符?

选 retries ?? 3。分别代入 0、undefined 和 null,结果是 0、3 和 3。

CHAPTER 10

数组、对象、Map 与解构

用 Python 列表推导式和字典经验,迁移到常见的数据处理链。

Python · 推导式
names = ["  Ada ", "", " Lin "]
cleaned = [s.strip() for s in names if s.strip()]
print(cleaned)
head, *tail = cleaned
print(head, tail)
输出 ['Ada', 'Lin'] Ada ['Lin']
TypeScript · map / filter
const names = ["  Ada ", "", " Lin "];
const cleaned = names.map(s => s.trim()).filter(s => s.length > 0);
const [head = "(empty)", ...tail] = cleaned;
console.log(JSON.stringify(cleaned));
console.log(head, JSON.stringify(tail));
输出 ["Ada","Lin"] Ada ["Lin"]

map 逐项变换,filter 按条件保留,find 返回第一个命中值或 undefined,some 判断是否存在。聚合时可写 nums.reduce((sum, n) => sum + n, 0),最后的初始值让空数组也得到确定结果。用 sort((a, b) => a - b) 做数值排序,默认排序按字符串比较。sort 会修改数组,[...nums].sort(...) 先复制外层。

TypeScript · 对象解构、改名、合并
const defaults = { name: "runner", retries: 2, nested: { log: false } };
const settings = { ...defaults, retries: 0 };
const { name: label, retries, ...rest } = settings;
settings.nested.log = true;
console.log(label, retries, JSON.stringify(rest));
console.log(defaults.retries, defaults.nested.log);
输出 runner 0 {"nested":{"log":true}} 2 true

对象展开的同名键由后写的属性覆盖。对象展开与数组展开都是浅复制。这里 settings.nested 和 defaults.nested 持有同一个对象。{ name: label } 的冒号表示把属性 name 绑定到局部变量 label。

要表达的数据常见写法读代码时要注意
同类元素序列string[] / Array<string>越界索引给出 undefined。默认的 strict 未启用 noUncheckedIndexedAccess。
固定位置和含义[string, number]元组在运行时仍是数组。可以用 readonly [string, number] 限制通过该引用修改。
按键查值Record<string, number> 或 Map<string, number>前者描述普通对象的类型,后者是运行时容器,能用对象作键,get 将缺失建模为 undefined。
去重与成员判断Set<string>new Set(items) 后用 .has(value) 检查成员。对象按身份去重。
TypeScript · Map、Set 与索引
const counts = new Map<string, number>();
for (const name of ["read", "read", "write"]) {
  counts.set(name, (counts.get(name) ?? 0) + 1);
}
const tags = [...new Set(["ts", "node", "ts"])];
console.log(JSON.stringify([...counts]));
console.log(counts.get("missing"), JSON.stringify(tags));
const empty: string[] = [];
console.log(empty[0]);
输出 [["read",2],["write",1]] undefined ["ts","node"] undefined

常见陷阱是用 Record<string, Value> 后把任意键都当成存在。有限集合用 Record<"read" | "write", Value> 强制填齐已知键,动态查找可以用 Map.get、显式 Value | undefined 或开启索引访问检查。

CHAPTER 11

函数、回调、闭包与 this

从 def、lambda 和闭包出发,读懂 TS 项目里大量出现的箭头函数与函数类型。

Python · 关键字参数
def label(name: str, *, prefix: str = "job") -> str:
    return f"{prefix}:{name}"

print(label("read"))
print(label("read", prefix="task"))
输出 job:read task:read
TypeScript · 用对象传命名选项
type Options = { prefix?: string };
function label(name: string, { prefix = "job" }: Options = {}): string {
  return `${prefix}:${name}`;
}

console.log(label("read"));
console.log(label("read", { prefix: "task" }));
输出 job:read task:read

函数名后的一对括号是参数,最后一个冒号后的 string 是返回类型。这里第二个参数先用 = {} 处理整个参数缺失,再用 prefix = "job" 处理对象属性缺失。n?: number 允许省略参数,函数体中 n 可能为 undefined。...args: string[] 把剩余位置参数收成数组,调用侧的 fn(...args) 把数组展开成实参。

TypeScript · 函数签名与箭头
type Transform = (value: string) => string;
const upper: Transform = value => value.toUpperCase();
const asEntry = (value: string) => ({ value, size: value.length });
const list = (...values: string[]) => values.map(upper).map(asEntry);
console.log(JSON.stringify(list("ts", "node")));
输出 [{"value":"TS","size":2},{"value":"NODE","size":4}]

(value: string) => string 在类型位置描述调用形状。value => value.toUpperCase() 在值位置创建函数,并借助上下文推断参数类型。箭头函数的表达式体自动返回结果,花括号块体需要 return。返回对象字面量时用 ({ value }) 包起来。

TypeScript · 闭包保留可变绑定
function counter(start = 0) {
  let n = start;
  return () => ++n;
}
const a = counter(10);
const b = counter();
console.log(a(), a(), b());
输出 11 12 1

两次调用 counter 创建独立的局部作用域。返回的函数仍引用各自的 n,因此能跨调用积累状态。普通函数和方法的 this 取决于调用方式。执行 obj.run() 时接收者是 obj,取出 const run = obj.run 后直接调用会丢失接收者。箭头函数从定义处捕获 this。

TypeScript · 传递方法时保留接收者
const job = {
  name: "read",
  run() { return this.name.toUpperCase(); },
};
const bound = job.run.bind(job);
const wrapped = () => job.run();
console.log(bound(), wrapped());
输出 READ READ

重载描述调用约定,函数体负责实际分支

TypeScript · 重载
function format(input: string): string;
function format(input: number): string;
function format(input: string | number): string {
  return typeof input === "number" ? input.toFixed(1) : input.trim();
}
console.log(format(3), format(" hi "));
输出 3.0 hi

调用侧看到上面两条重载签名。第三条给实现使用。传入类型仍为 string | number 的变量时,两条公开重载可能都匹配不上。这个例子两种输入返回相同类型,也可以直接公开一个联合参数签名。void 作为回调返回类型时表示调用方会忽略结果,例如 forEach 接受返回值会被丢弃的回调。

CHAPTER 12

对象形状、接口与类

分清形状约束、实例身份和可变状态。

TypeScript · 结构兼容
interface Job {
  readonly id: string;
  retries?: number;
}
function describe(job: Job): string {
  return `${job.id}/${job.retries ?? 0}`;
}
const task = { id: "read", owner: "Lin" };
console.log(describe(task));
// describe({ id: "read", owner: "Lin" }); // 多余属性检查
输出与诊断 read/0 取消最后一行注释:对象字面量含 Job 未声明的 owner。

只要变量的形状满足接口,它就可以传给 describe。可以把这想成由检查器提前确认的鸭子类型。直接传入新鲜对象字面量时,额外的属性检查常用于捕捉拼写错误。readonly 限制通过这个类型视图写入,约束不会递归,也不会冻结 JS 对象。Object.freeze 会在运行时冻结对象的外层。

retries?: number 表示属性可缺失,读取时可能得到 undefined。retries: number | undefined 要求键存在,值可为 undefined。开启 exactOptionalPropertyTypes 后,若希望可选属性还允许显式写入 undefined,要把它写进属性的值类型。

TypeScript · interface、type 与交叉类型
interface Identified { id: string }
interface NamedJob extends Identified { name: string }
type Timed = { timeoutMs: number };
type TimedJob = NamedJob & Timed;
const job: TimedJob = { id: "a", name: "read", timeoutMs: 500 };
console.log(job.name, job.timeoutMs);
输出 read 500

interface 可用 extends 扩展对象形状,同名接口在适用作用域可合并。type 还可为联合、元组、原始类型及类型计算起别名。A & B 表示同一个值要同时满足两种约束,遇到同名字段类型冲突可能得到 never。运行时合并对象由 {...a, ...b} 完成。

Python · self 与实例
class Counter:
    def __init__(self, name: str):
        self.name = name
        self.n = 0

    def step(self) -> str:
        self.n += 1
        return f"{self.name}:{self.n}"

print(Counter("job").step())
输出 job:1
TypeScript · this 与私有字段
interface Stepper { step(): string }

class Counter implements Stepper {
  #n = 0;
  readonly name: string;

  constructor(name: string) {
    this.name = name;
  }

  step(): string {
    this.#n += 1;
    return `${this.name}:${this.#n}`;
  }
}
const c: Stepper = new Counter("job");
console.log(c.step());
console.log(c instanceof Counter);
输出 job:1 true

class 同时有类型和运行时值两面。new Counter(...) 产生对象,instanceof Counter 检查该构造函数的原型是否出现在对象的原型链上。implements 用于静态检查,接口不进入原型链。TS 的 private 主要由类型检查器约束,JS 的 #n 有运行时私有语义。读到 constructor(public name: string) {} 时,public 参数属性是 TS 简写,会在实例上创建并初始化属性。

类的普通方法由实例通过原型链找到,实例字段属于每个实例。extends Base 建立继承关系,派生构造器在使用 this 之前调用 super(...)。static 成员从类本身访问。abstract 声明由派生类完成的契约。

CHAPTER 13

联合、收窄与穷尽检查

联合类型让状态差异进入类型系统,再用真实分支把类型范围缩小。

TypeScript · 从 string | number 收窄
function normalize(id: string | number): string {
  if (typeof id === "string") {
    return id.trim();
  }
  return id.toString(10);
}
console.log(normalize("  abc "), normalize(42));
输出 abc 42

联合 A | B 代表值可能属于任一成员。检查器只允许无条件访问所有成员共同支持的操作。typeof、instanceof、字面量比较、in 和 Array.isArray 都能提供收窄线索。记得 typeof null === "object",要处理可空对象时先显式排除 null。

输入Result = Ok | Failed
检查 result.kind运行时比较字符串
分支内获得专属字段ok → value;error → message
图 02 · 运行时检查驱动静态收窄。标签字段应为各分支独有的字面量。
TypeScript · 可辨识联合
type Result =
  | { kind: "ok"; value: number }
  | { kind: "error"; message: string };

function unreachable(value: never): never {
  throw new Error(`Unexpected result: ${JSON.stringify(value)}`);
}
function show(result: Result): string {
  switch (result.kind) {
    case "ok": return `value=${result.value}`;
    case "error": return `error=${result.message}`;
    default: return unreachable(result);
  }
}
console.log(show({ kind: "ok", value: 0 }));
console.log(show({ kind: "error", message: "timeout" }));
输出 value=0 error=timeout

never 表示在当前静态分析里没有可达的值,也可作为总会抛异常的函数返回类型。新增 { kind: "cancelled" } 后,若忘记补 case,默认分支仍可能收到 cancelled,便会在传给 unreachable 时得到编译诊断。用 kind、type 或 status 区分请求结果、事件、命令和状态,是阅读 TS 项目时值得优先识别的结构。

TypeScript · 字面量为何被拓宽
type Job = { kind: "read" | "write"; path: string };
const loose = { kind: "read", path: "in.txt" }; // kind 推断为 string
const exact = { kind: "read", path: "in.txt" } satisfies Job;
const fixed = ["read", "write"] as const;
console.log(exact.kind, fixed[1]);
输出 read write

普通对象的属性可被重新赋值,初始化时的 "read" 常被拓宽成 string。satisfies Job 检查表达式满足目标约束,并保留表达式自己的推断结果。as const 让字面量保留精确值,将对象字面量属性标为只读,并把数组字面量推断为只读元组。两者在运行时都被擦除。

自测 · 为什么 if (value) 可能把合法输入排除?

如果 value 的类型是 number | undefined,0 是合法数值但在真假判断中为假。用 value !== undefined 表达检查的真实目的。

CHAPTER 14

泛型保留输入与输出的关系

把 T 看成由调用决定的类型参数。像 Python 的 TypeVar 一样,重点在各个位置的类型如何关联。

Python · TypeVar 泛型函数
from typing import TypeVar

T = TypeVar("T")

def first(items: list[T]) -> T | None:
    return items[0] if items else None

print(first([10, 20]))
print(first([]))
输出 10 None
TypeScript · 从实参推断 T
function first<T>(items: readonly T[]): T | undefined {
  return items[0];
}
const a = first([10, 20]);     // number | undefined
const b = first<string>([]);  // string | undefined
console.log(a, b);
输出 10 undefined

first<T> 宣告了类型参数。readonly T[] 允许传入可变或只读数组,函数体只能通过这个引用读取。T | undefined 表达空数组的运行结果。若改成 any[] → any,调用后就失去了元素类型的线索。

TypeScript · K 受 T 的键约束
function get<T extends object, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
const job = { name: "read", retries: 2 };
const name = get(job, "name");       // string
const retries = get(job, "retries"); // number
console.log(name.toUpperCase(), retries + 1);
// get(job, "retry");               // 不在 keyof T 中
输出 READ 3

这里的 extends 表示类型约束。第一步推断 T = { name: string; retries: number },第二步得到 keyof T = "name" | "retries",第三步用传入的键决定 K,最后 T[K] 读出对应属性的类型。keyof 计算类型层的键,Object.keys 才会枚举某个实际对象的键。

TypeScript · 泛型数据结构
type Outcome<T, E = string> =
  | { ok: true; value: T }
  | { ok: false; error: E };

function parseCount(text: string): Outcome<number> {
  if (!/^\d+$/.test(text)) return { ok: false, error: "expected digits" };
  const count = Number(text);
  if (!Number.isSafeInteger(count)) return { ok: false, error: "unsafe integer" };
  return { ok: true, value: count };
}
for (const raw of ["0", "x", "9007199254740993"]) {
  const result = parseCount(raw);
  console.log(result.ok ? result.value : result.error);
}
输出 0 expected digits unsafe integer

E = string 给类型参数默认值。只传 Outcome<number> 时错误载荷为字符串,也可以在另一个接口里写 Outcome<User, Error>。泛型从实参推断或由调用方提供,执行函数时没有一个名叫 T 的运行时参数。想按类别构造实例时,需要传构造函数或工厂等运行时值。

CHAPTER 15

类型变换与推断

顺着符号从里向外读,弄懂项目里常见的 keyof、typeof、映射类型与 infer。

TypeScript · 从运行时配置派生类型
const actions = ["read", "write"] as const;
type Action = (typeof actions)[number];
type Handler = (path: string) => string;

const handlers = {
  read: path => `read ${path}`,
  write: path => `write ${path}`,
} satisfies Record<Action, Handler>;

console.log(actions.map(action => handlers[action]("a.txt")).join("\n"));
输出 read a.txt write a.txt
  1. typeof actions 在类型位置获取变量的静态类型,这里是 readonly ["read", "write"]。表达式里的 typeof actions 则在运行时得到 "object"。
  2. [number] 获取数字索引可读出的元素类型,即 "read" | "write"。
  3. Record<Action, Handler> 要求每个动作名对应一个函数。添加动作后缺少相应实现,检查器会指出。
类型工具怎么读常见场景
Pick<Job, "id" | "name">只取这些字段列表摘要
Omit<Job, "id">去掉这些字段服务端创建 ID 的输入类型
Partial<Job>各字段变成可选浅层补丁对象
Required<Job> / Readonly<Job>字段改为必选 / 只读解析完默认值的配置 / 对外只读视图
Extract<Event, { kind: "done" }>保留能赋给目标类型的联合成员挑出某一类事件载荷
ReturnType<typeof load> / Awaited<T>读出返回类型 / 展开等待后的值类型从异步函数签名导出数据模型
TypeScript · 映射和条件类型
type Settings = { path: string; retries: number };
type Readers<T> = {
  [K in keyof T]: () => T[K];
};
const readers: Readers<Settings> = {
  path: () => "out.txt",
  retries: () => 3,
};

type Item<T> = T extends readonly (infer U)[] ? U : never;
type Name = Item<readonly ["read", "write"]>;
const action: Name = "read";
console.log(readers.path(), readers.retries(), action);
输出 out.txt 3 read

[K in keyof T] 逐个键生成属性,类似在类型层做字典推导。T extends ... ? X : Y 在类型层做可赋值关系的判断。infer U 让检查器在匹配数组形状时提取元素类型。条件左侧为裸类型参数时会对联合成员分别计算,例如 Item<string[] | number[]> 得到 string | number。可以用 [T] extends [...] 包住类型,按整个联合作判断。

读复杂类型时用纸笔代入具体输入。例如从 Awaited<ReturnType<typeof load>> 最内层开始,依次写出 () => Promise<Job>、Promise<Job>、Job。静态类型变换不会替你删字段或复制对象,若要真正移除数据,要做运行时投影,例如 const { secret, ...publicData } = job。

CHAPTER 16

外部数据、断言与异常

给来自文件、命令行或网络的值安排明确的运行时检查。

文本文件、请求、环境变量
解析JSON.parse → unknown
校验检查对象与关键属性
可信结构业务函数读取具体字段
图 03 · 解析成功只代表格式可读。校验成功才支持关于字段形状的判断。
TypeScript · 两类失败与自定义类型守卫
type Job = { name: string; retries: number };

function isJob(value: unknown): value is Job {
  return typeof value === "object" && value !== null
    && "name" in value && typeof value.name === "string"
    && "retries" in value && typeof value.retries === "number"
    && Number.isSafeInteger(value.retries) && value.retries >= 0;
}

function parseJob(text: string): Job {
  const raw: unknown = JSON.parse(text);
  if (!isJob(raw)) throw new Error("invalid job shape");
  return raw;
}

for (const text of ['{"name":"read","retries":0}', '{"name":"read"}', '{']) {
  try {
    console.log(parseJob(text).name.toUpperCase());
  } catch (err: unknown) {
    if (err instanceof SyntaxError) console.log("invalid JSON");
    else console.log(err instanceof Error ? err.message : String(err));
  }
}
输出 READ invalid job shape invalid JSON

JSON.parse 的库签名返回 any,这里主动接到 unknown,避免不可信值绕过检查。value is Job 告诉检查器:守卫为真时可以按 Job 读字段。守卫本身的判断由作者负责,漏掉 retries 的检查也可能照样编译。处理外部输入时可检查类型之外的业务约束,例如次数非负和路径非空。

语法影响阅读时的问题
any允许许多未检查的操作,结果还能继续传播为 any数据来自哪里,在哪里获得保证?
unknown可接收任意值,使用前要收窄检查覆盖了哪些字段或分支?
value as Job可接受的断言改变静态视图运行时是否有证据支持这一形状?
value!在类型上排除 null 和 undefined哪个前置条件保证了值存在?
String(value) / Number(value)运行时转换是否要拒绝 NaN、空字符串等特殊结果?
TypeScript · 断言与真实转换的区别
const raw: unknown = "42";
const asserted = raw as number;
const converted = Number(raw);
console.log(typeof asserted, asserted + 1);
console.log(typeof converted, converted + 1);
输出 string 421 number 43

JS 可以抛出任何值,catch 里先用 instanceof Error 收窄,再取 message。finally 在成功、异常或提前返回时都会执行,适合释放资源。可预期且需要调用者分类处理的失败,也可显式返回第 14 章那样的 Outcome。

CHAPTER 17

import、export 与依赖解析

把 Python 的 import 经验迁移到 Node.js,同时建立 ESM 和 CommonJS 的概念。

Python · 模块导入
# task.py
def run(name: str) -> str:
    return name.upper()

# main.py
from task import run
print(run("read"))
python main.py READ
TypeScript · Node ESM,先编译再运行
// task.ts
export type Task = { name: string };
export function run(task: Task) {
  return task.name.toUpperCase();
}

// main.ts
import { run, type Task } from "./task.js";
const task: Task = { name: "read" };
console.log(run(task));
node dist/main.js READ

右侧是两个文件。以第 21 章的 NodeNext 配置为例,TypeScript 检查 main.ts 时可用 ./task.js 找到 task.ts。编译产物保留 ./task.js,Node 运行时会找到真正的 dist/task.js。type Task 这一导入项被擦除。只有类型时可直接写 import type { Task } from "./task.js"。

看到的写法含义
import { run as execute } from "./task.js"命名导入并在当前文件改名。
import run from "./task.js"导入模块的默认导出,由 export default 对应提供。
import * as task from "./task.js"获得模块命名空间,通过 task.run 读导出。
export { run } from "./task.js"从当前入口重新导出。读 index.ts 时可能还要再跳一层。
import "./setup.js"为了执行模块顶层的初始化逻辑而导入。
const task = await import("./task.js")在执行到该表达式时动态加载,结果是模块命名空间的 Promise。
const task = require("./task.cjs")CommonJS 的典型加载写法,导出对象通常来自 module.exports。

ESM 的静态导入由宿主在模块求值前处理,导入绑定随导出绑定更新。依赖模块的顶层代码会参与求值,循环依赖时可能在绑定初始化前就被读取。package.json 里的 "type": "module" 让该包范围下的 .js 明确按 ESM 解释。.mjs 明确是 ESM,.cjs 明确是 CommonJS。编译时 .mts、.cts 可分别输出为两者。

以 import { readFile } from "node:fs/promises" 为例,node: 指向 Node 内置模块。"some-package" 这样的裸模块说明符通常交给包解析,包的 exports 能指定不同条件的公共入口。"./task.js" 则相对当前模块解析。paths 可以告诉 TS 检查器怎样查别名,运行时要由 Node 的包映射、打包器等机制提供对应解析。

两套问题要分别查。 TS2307 属于检查时的解析问题。ERR_MODULE_NOT_FOUND 来自 Node,先查产物路径、文件扩展名、依赖是否安装和包导出规则。

CHAPTER 18

Promise、async/await 与事件循环

学会预测输出顺序,理解等待 I/O 时函数和运行时分别在做什么。

Python · 调用 async def 得到协程对象
import asyncio

async def work():
    print("work")
    return 7

async def main():
    pending = work()
    print("after call")
    print(await pending)

asyncio.run(main())
输出 after call work 7
TypeScript · async 函数立即进入函数体
async function work(): Promise<number> {
  console.log("work");
  return 7;
}

async function main() {
  const pending = work();
  console.log("after call");
  console.log(await pending);
}
void main();
输出 work after call 7

调用 JS 的 async 函数会同步执行到首次挂起或完成,同时给调用者一个 Promise。return 7 让 Promise 兑现为 7。throw 让 Promise 拒绝。await 会暂停当前 async 函数后续的执行,即使等待的值已兑现,续段也会稍后调度。调用者和事件循环可以继续处理别的工作。示例中的 void main() 丢弃返回值,真实入口仍需处理可能的拒绝,例如 main().catch(report)。

TypeScript · 预测输出顺序
console.log("A: sync start");

setTimeout(() => console.log("D: timer"), 0);
Promise.resolve().then(() => console.log("C: promise"));

console.log("B: sync end");
输出 A: sync start B: sync end C: promise D: timer
实验 · 在浏览器运行上方的调度顺序

点击运行后,这里逐行显示输出。
当前调用栈执行脚本,记录 A、B
Promise 微任务运行 then 回调,记录 C
计时器回调到达可调度时机,记录 D
图 04 · 本例没有混入 Node 特有的 process.nextTick 或其他事件循环阶段。

同步函数通过调用栈执行。网络和文件 I/O 由宿主协调,完成后相关回调或 Promise 续段才有机会运行。同步的长循环仍会占用 JS 执行线程。setTimeout(fn, 0) 代表尽早调度,运行顺序还受当前任务和队列影响。

TypeScript · 并发启动,按输入顺序返回结果
function delayed(value: string, ms: number): Promise<string> {
  return new Promise(resolve => setTimeout(() => resolve(value), ms));
}
async function main() {
  const results = await Promise.all([delayed("slow", 25), delayed("fast", 5)]);
  console.log(JSON.stringify(results));
}
void main();
输出 ["slow","fast"]

两个 delayed 在构造数组时就启动。Promise.all 兑现后的数组保持输入顺序,任一个拒绝会使组合 Promise 拒绝,其他操作仍会继续,除非另外取消。要收集成功与失败两类结果,用 Promise.allSettled。异步有先后依赖时按次序 await;独立操作可先发起再一起等待。需要逐项等待时用 for...of 加 await,forEach(async ...) 会丢弃回调返回的 Promise。

自测 · 两次 await 写在循环里,会发生什么?

若每次迭代先创建一个 Promise 再等待它,第二次操作要等第一次完成才启动。若循环前已把两项操作都启动,随后逐项等待只决定消费顺序。

CHAPTER 19

异步迭代、流式事件与取消

读懂一串分批到达的值,以及调用方如何通知底层操作停止。

TypeScript · async function* + for await
type Progress =
  | { kind: "chunk"; text: string }
  | { kind: "done" };

async function* stream(): AsyncGenerator<Progress> {
  yield { kind: "chunk", text: "hello" };
  await Promise.resolve();
  yield { kind: "chunk", text: " world" };
  yield { kind: "done" };
}

async function main() {
  let message = "";
  for await (const event of stream()) {
    if (event.kind === "chunk") message += event.text;
    else console.log(message);
  }
}
void main();
输出 hello world

和 Python 的异步生成器与 async for 类似,async function* 返回可异步迭代的生成器。每次请求下一项都会得到一个 Promise,兑现为迭代结果对象,形如 { value, done }。for await...of 替你等待、取值并判断结束。这里的业务事件 { kind: "done" } 和迭代器自身的 done: true 属于两层协议。提前 break 会在可用时调用并等待迭代器的 return(),生成器可在 finally 中清理资源。

TypeScript · Node.js · 把 signal 传给支持取消的 API
import { setTimeout as delay } from "node:timers/promises";

const controller = new AbortController();

async function run(signal: AbortSignal) {
  try {
    await delay(1000, undefined, { signal });
    console.log("finished");
  } catch (err) {
    if (err instanceof Error && err.name === "AbortError") {
      console.log("cancelled");
      return;
    }
    throw err;
  }
}

const pending = run(controller.signal);
controller.abort();
await pending;
输出 cancelled

按第 21 章的 ESM 配置运行这段代码,顶层 await 在模块中有效。取消是一种合作机制。调用 abort() 后,信号被标为已取消,支持该信号的 API 会按各自的契约作出响应。自己实现长操作时,要在合适的边界检查 signal.aborted 或监听 abort。如果函数接收了信号却没有继续传给实际工作,底层 I/O 仍可能继续。

CHAPTER 20

tsc、运行器与调试

从启动脚本追到真正执行的文件,区分检查、转译、打包和宿主执行。

npm run build读取 package.json 的 scripts
本地 tsc读取 tsconfig.json,检查并生成 JS
node dist/main.js解析 ESM 依赖,运行模块
图 05 · npm 负责依赖与脚本,tsc 负责检查和产物,node 负责执行。
设置 / 文件解决什么问题
package.json项目脚本、依赖、JS 模块类型和包元数据。package-lock.json 记录依赖解析结果。
strict打开一组严格检查,例如空值处理和隐式 any。noUncheckedIndexedAccess 与 exactOptionalPropertyTypes 是另行选择的检查。
target / libtarget 影响输出的 JS 语法级别以及默认库选择;lib 决定可见的标准 API 声明。两者都不会自动补齐宿主缺失的 API。
module / moduleResolution模块的发射和解释方式,以及检查器如何寻找导入目标。让它们与实际宿主或打包器的规则一致。
rootDir / outDir控制源文件相对路径如何映射到输出目录。用 include 指定初始文件集合。
noEmit / noEmitOnError前者只检查不写产物,后者发现错误就不写新产物。旧的 dist 文件可能仍留在磁盘上。
sourceMap写出从 JS 位置映射回 TS 的数据。Node 启用 --enable-source-maps 后可在堆栈中使用。
.d.ts 与 @types/node描述值和 API 的类型。Node 的 API 实现来自运行时,安装类型声明只让检查器认识它。

转译是把 TS/新语法变成目标 JS。打包根据依赖图生成一个或多个交付文件,可以同时承担转译。一个项目可以用转译器快速输出,用 tsc --noEmit 单独检查。支持类型擦除的 Node 版本还能运行部分 .ts:它擦除可消除的类型语法,不做类型检查,也不会用 tsconfig.json 改写路径别名或降级语法。普通 enum、构造器参数属性等需要生成 JS 的语法,单纯擦除无法处理。直接运行 TS 的导入后缀要指向运行时实际可加载的文件。

TypeScript · 比较类型和运行时错误
const names: string[] = [];
const first = names[0];
console.log(first.toUpperCase());
默认 strict 下的现象 类型检查可以通过。 运行时:TypeError: Cannot read properties of undefined (reading 'toUpperCase') 启用 noUncheckedIndexedAccess 后:编译期提示 first 可能是 undefined。

调试时先辨认错误所在的层。TS... 诊断看类型、库声明和编译配置。ERR_MODULE_NOT_FOUND 看入口、依赖和文件后缀。TypeError 看真实数据与调用堆栈。若代码刚改过,先确认执行命令指向本次成功构建的产物。node --enable-source-maps dist/main.js 可以让堆栈定位到原始 TS 行。

CHAPTER 21

实战 · 一个任务命令行工具

从空目录搭项目,把运行时校验、判别联合、异步文件读取和 ESM 接在一起。

这个 CLI 从 JSON 文件读任务,接受 count 和 echo 两种命令。以下用 Node.js 24、npm 与 TypeScript 5.9。Windows PowerShell 可以直接使用下面的 npm.cmd,macOS/Linux 使用 npm。在自己选的练习位置执行,接下来把各代码框保存为标出的文件。

PowerShell · 初始化项目
mkdir ts-task-lab
cd ts-task-lab
npm.cmd init -y
npm.cmd install -D typescript@5.9.3 @types/node@24
mkdir src
产物 创建 package.json、package-lock.json、node_modules/ 与 src/。

在生成的 package.json 中保留 devDependencies,添加 "type": "module" 并更新 scripts。下面给出一个完整文件形状,也可直接替换。

package.json
{
  "name": "ts-task-lab",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "check": "tsc --noEmit",
    "build": "tsc",
    "start": "node --enable-source-maps dist/main.js"
  },
  "devDependencies": {
    "@types/node": "^24.0.0",
    "typescript": "5.9.3"
  }
}
npm 脚本入口 check → 类型检查 build → 生成 JS start → 运行产物

如果替换了整个 package.json,运行一次 npm.cmd install 同步锁文件。这里把类型包作为开发依赖,因为执行产物时只用 Node 内置模块。

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "types": ["node"],
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "verbatimModuleSyntax": true,
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noEmitOnError": true,
    "sourceMap": true,
    "rootDir": "src",
    "outDir": "dist"
  },
  "include": ["src/**/*.ts"]
}
路径映射 src/main.ts → dist/main.js + dist/main.js.map src/task.ts → dist/task.js + dist/task.js.map
src/task.ts
export type Task =
  | { kind: "count"; items: string[] }
  | { kind: "echo"; text: string };

function isStringArray(value: unknown): value is string[] {
  return Array.isArray(value) &&
    value.every((item: unknown) => typeof item === "string");
}

export function parseTask(value: unknown): Task {
  if (typeof value !== "object" || value === null || !("kind" in value)) {
    throw new Error("expected a task object");
  }

  switch (value.kind) {
    case "count":
      if ("items" in value && isStringArray(value.items)) {
        return { kind: "count", items: value.items };
      }
      break;
    case "echo":
      if ("text" in value && typeof value.text === "string") {
        return { kind: "echo", text: value.text };
      }
      break;
  }
  throw new Error("invalid task fields");
}

function unreachable(task: never): never {
  throw new Error(`Unexpected task: ${JSON.stringify(task)}`);
}

export function execute(task: Task): string {
  switch (task.kind) {
    case "count": return `count=${task.items.length}`;
    case "echo": return `echo=${task.text}`;
    default: return unreachable(task);
  }
}
输入 → 返回值 / 异常 {"kind":"count","items":["a","b"]} → "count=2" {"kind":"echo","text":"hello"} → "echo=hello" {"kind":"count","items":[3]} → Error: invalid task fields

parseTask 接收的是 JSON 解码后的值。JSON 数组没有 JavaScript 稀疏数组的空洞,所以这里用 every 检查每项。若把守卫复用于接受任意 JS 数组的公共接口,要考虑 every 会跳过空洞。execute 接收已经验证过的 Task,可以根据标签直接访问对应字段。

src/main.ts
import { readFile } from "node:fs/promises";
import { execute, parseTask, type Task } from "./task.js";

async function main(): Promise<void> {
  const path = process.argv[2];
  if (path === undefined) {
    throw new Error("usage: npm start -- task.json");
  }

  const text = await readFile(path, "utf8");
  const raw: unknown = JSON.parse(text);
  const task: Task = parseTask(raw);
  console.log(execute(task));
}

main().catch((error: unknown) => {
  console.error(error instanceof Error ? error.message : String(error));
  process.exitCode = 1;
});
行为 成功:将结果写到 stdout,退出码 0。 失败:将错误写到 stderr,退出码 1。
task.json
{"kind":"count","items":["read","write","test"]}
业务输出 count=3
bad-task.json
{"kind":"count","items":[1]}
错误输出 invalid task fields
PowerShell · 检查、构建并运行
npm.cmd run check
npm.cmd run build
node --enable-source-maps dist/main.js task.json
node --enable-source-maps dist/main.js bad-task.json
$LASTEXITCODE
关键输出(省略 npm 脚本回显) count=3 invalid task fields 1

也可以运行 npm.cmd start -- task.json。-- 之后的参数传给脚本。相对文件路径按当前工作目录解析,示例命令在练习项目根目录执行。process.argv[0] 是 Node 可执行文件路径,[1] 是入口脚本路径,[2] 才是第一项业务参数。

main.ts参数 → readFile → JSON.parse
parseTaskunknown → Task 或 Error
execute按 kind 收窄 → 字符串
进程出口stdout 或 stderr + 退出码
图 06 · 从启动到退出。parseTask 是不可信输入进入业务逻辑的边界。
练习一 · 给工具添加 join 任务

新增 { kind: "join"; items: string[]; separator?: string }。先只改 Task,再运行 npm.cmd run check,观察遗漏 case 时的诊断。接着在解析边界接受缺失的 separator 或字符串,并在执行分支调用 task.items.join(task.separator ?? ",")。输入 {"kind":"join","items":["a","b"],"separator":""} 应输出 ab。

练习二 · 跟踪三类失败

运行时分别尝试省略路径、传入只含 { 的文件和传入 bad-task.json。前者在参数检查失败,中间在 JSON.parse 失败,最后一个在 parseTask 失败。三个错误都会走入口的 .catch(),可以逐个预测会执行到哪一行。

练习三 · 把编译诊断和模块加载错误分开

把 from "./task.js" 临时改为 from "./missing.js",运行类型检查会得到找不到模块的诊断。恢复并重新构建后,若暂时改名 dist/task.js,再运行 JS 就会得到 ERR_MODULE_NOT_FOUND。最后恢复文件名或重新构建。

下次打开一个陌生 TS 项目

  1. 从 package.json 的 scripts 找实际入口,再看 tsconfig.json 的模块模式、检查设置与输出目录。
  2. 从入口跟到参数、文件或网络输入,标记值第一次被校验的位置。遇到 as 或 !,向前寻找约束成立的证据。
  3. 先读联合类型的标签与分支,再读各分支的字段。遇到泛型,代入一个具体调用值,把 T、K 等依次写下来。
  4. 每遇到 await、回调或 yield,记下操作何时启动、结果怎么传递、失败和取消由谁处理。
  5. 选一个具体输入,预测控制台输出和退出码,再运行验证。
REFERENCE

按主题查官方文档