先把一小段 TS 读顺
第 01 至 06 章只写 TypeScript。读每段代码时,先按顺序读出它做了什么,再猜会显示什么,最后看代码框下方的结果。每章都有一道可以先猜再展开的练习。
从第 07 章开始,再拿 Python 来对照已经学过的概念。示例按段独立阅读,跨文件示例会注明文件名。跟着动手时,可以先在本地跑第一章的 hello.ts。
写下第一行 TypeScript
先写出一条能运行的语句,再认识这一行里的点、括号、引号和分号。
console.log("你好,TypeScript!");console是运行环境已经提供的一个对象,可以先把它理解为一组和控制台有关的工具。- 点
.用来取出对象上的成员。console.log取出负责打印的函数。 (...)调用这个函数,括号里是交给它的内容。"你好,TypeScript!"是一个字符串,引号标记文字的起止位置。- 分号
;在这里结束这条语句。入门示例统一写上分号。语法符号使用半角字符,字符串里的文字可以使用中文标点。
把代码存进文件,然后在终端运行
以下动手示例使用 Node.js 24。打开 PowerShell,输入 node --version 确认环境。用文本编辑器新建文件,存为 hello.ts。在该文件所在的目录打开终端,再输入下方命令。上一框是文件内容,下一框是终端命令。
node hello.ts每个示例可以单独保存运行。练习时可以反复替换 hello.ts 的内容,保存后再次执行命令。当前先观察程序做了什么,后面再讲 Node 如何处理 .ts 和怎样检查类型。
顺序、注释与表达式
// 这一行写给读代码的人
console.log("2 + 3");
console.log(2 + 3);
console.log("总计", 2 + 3);程序从上到下执行。// 到行末是注释。第二行代码中的 "2 + 3" 整体是一段文字;下一行中的 2 + 3 会先计算,得到 5 再交给 log。这里能产生值的 2 + 3 叫表达式。逗号分隔多个参数,log 在同一行显示它们。
自测 · console.log("10", 10 + 1); 会显示什么?
显示 10 11。第一个参数是文本,第二个参数是加法的结果。直接打印时,字符串外的引号一般不会出现在终端里。
声明变量,认识三种基本类型
用名字保存值。先学会辨认声明、类型注解、赋值和取值。
const title: string = "整理笔记";
let count: number = 1;
const ready: boolean = true;
count = count + 2;
console.log(title, count, ready);| 部分 | 在第一行里的意思 |
|---|---|
const | 声明一个初始化后不能重新赋值的名字。需要重新赋值时用 let。 |
title | 变量名。后面直接写 title 就读取它保存的值。 |
: string | 类型注解,说明这个名字应保存字符串。数值用 number,真假值用 boolean。 |
= "整理笔记" | 初始化,给变量第一个值。后面的 count = count + 2 先算等号右边,再把新值写回左边。 |
初始值已经给出类型线索
let score = 10;
score = score + 5;
const label = "得分";
console.log(label, score);
console.log(typeof score, typeof label, typeof false);从 10 就能推断 score 应该装数值。这里的 typeof 在运行时查看值的类别。若加一行 score = "十五";,TypeScript 类型检查会给出 TS2322,因为字符串不能赋给 number。第 20 章会演示如何独立运行检查。直接运行 node hello.ts 主要用于观察执行行为。
const name = "小林";
const done = 2;
console.log(`${name}已经完成 ${done + 1} 项。`);模板字符串的边界是反引号 `。遇到 ${...},先计算里面的表达式,再把结果放进文字。常见错误是把反引号换成普通引号,那样会原样打印 ${name}。
自测 · 要每天增加 done,该用 const 还是 let?
用 let done = 2;。接下来执行 done = done + 1;,再打印 done,输出是 3。
写条件,让程序选择一条路
区分赋值和比较,读懂括号中的条件和花括号中的语句。
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,于是打印「通过」。这一组分支只执行其中一块。最后一行在花括号外,所以无论选中哪条分支,都会打印「评估结束」。
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);= 写入值,=== 比较是否相等,!== 比较是否不等。&& 用来组合「两个条件都成立」,|| 表示「至少一个条件成立」,! 反转一个条件。刚开始可以用括号明确每个子条件的边界。
自测 · 把第一段的 score 改为 90,会输出什么?
先输出「优秀」,再输出「评估结束」。第一条条件已经成立,后面的 else if 不再进入。
用数组装多个值,再逐个处理
认识方括号的两种写法,学会用循环完成一次累计。
const tasks: string[] = ["读资料", "写笔记"];
console.log(tasks[0]);
console.log(tasks[1]);
tasks.push("做练习");
console.log(tasks.length);string[] 在类型位置读作「字符串数组」。等号右边的 ["读资料", "写笔记"] 创建一个数组。变量后面的 [0] 是索引,取第一项。.push(...) 调用数组的方法追加一项,.length 读取长度,所以后面没有圆括号。const tasks 固定这个名字所指向的数组,仍可以往数组里加元素。
const costs = [2, 4, 3];
let total = 0;
for (const cost of costs) {
total = total + cost;
console.log("当前总计", total);
}
console.log("最终总计", total);for (const cost of costs) 读作「从 costs 中依次取一个值,这一次叫它 cost」。花括号内的代码重复执行。total 放在循环外,只初始化一次。第一轮算 0 + 2,第二轮接着算 2 + 4,第三轮算 6 + 3。若把 let total = 0; 挪进循环,每轮都会从零重新开始。
需要索引时,用三段式 for
const tasks = ["读资料", "写笔记"];
for (let i = 0; i < tasks.length; i = i + 1) {
console.log(i + 1, tasks[i]);
}圆括号里的三个部分用分号隔开。let i = 0 只执行一次。每轮开始前检查 i < tasks.length。如果条件成立,执行花括号里的代码,再执行 i = i + 1,回到条件检查。索引从零开始,给人看的编号用 i + 1。
只要条件成立就重复,用 while
let remaining = 3;
while (remaining > 0) {
console.log(remaining);
remaining = remaining - 1;
}
console.log("开始");while 在每一轮前判断条件。这里每轮都减少 remaining,到零时退出。循环里遇到 break 会立即离开循环,遇到 continue 会跳过当前轮余下的语句。写 while 时留意每条分支是否都能让循环最终结束。
自测 · costs 如果是 [],还有哪一行输出?
循环体执行零次,最终输出 最终总计 0。动手时可以写成 const costs: number[] = [];,明确空数组今后应保存数值。
写一个函数,给它参数并接住返回值
把声明和调用分开看,再识别项目里常见的箭头写法。
function add(a: number, b: number): number {
return a + b;
}
const sum = add(2, 3);
console.log(sum);
console.log(add(sum, 1));function add定义一个名叫add的函数。程序随后遇到add(2, 3)才会执行函数体。(a: number, b: number)声明两个参数及各自的类型。调用add(2, 3)时,a得到2,b得到3。- 右括号后的
: number是返回类型。return a + b;结束本次调用,把计算结果交回调用处。 const sum = add(2, 3);把返回的值保存到sum。下面的console.log(sum)才把它显示出来。
箭头函数的两种函数体
const twice = (n: number) => n * 2;
const triple = (n: number) => {
return n * 3;
};
console.log(twice(4), triple(4));=> 左边是参数,右边是函数体。只有一个表达式时,它的结果自动返回。右边若是花括号语句块,需要用 return 交回结果。第 11 章会进一步讲回调和闭包,初学时先练习从调用找到函数定义,再顺着 return 找到返回值。
自测 · console.log(add(3, add(1, 2))); 会输出什么?
输出 6。先执行内层的 add(1, 2),得到 3;外层实际执行 add(3, 3)。
用对象把相关值放在一起
从属性读写过渡到对象类型,然后串起前六章的写法。
const task = { title: "整理笔记", done: false };
console.log(task.title, task.done);
task.done = true;
console.log(task.title, task.done);等号右侧的 { ... } 创建一个对象。title: "整理笔记" 是一个属性,冒号左边是键,右边是值。属性用逗号分隔。task.title 读取 title,task.done = true; 修改 done。这里的花括号是在表达式中构造值,条件或函数中的花括号则包住一组语句。
把对象的形状写成类型
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));
}type Task = { ... }给一份类型起名字,描述有哪些属性、每个属性接受什么样的值。这里的title: string写的是字段类型。describe(task: Task)的参数需要符合这份类型。函数先看done,再返回一条文字。命中第一个return时,函数立即结束。Task[]表示数组中每一项都是符合Task形状的对象。循环把它们逐个交给describe,再打印返回值。
自测 · 把第二项的 done 改为 true,再添加第三项
第二行会变成 已完成:做练习。如果第三项是 { title: "复盘", done: false },就会再打印一行 待处理:复盘。
基础练习 · 先试着独立声明一个任务数组,写一个函数返回任务标题,用 for...of 把它们打印出来。能读写这些语句后,进入第 07 章,把刚学的语法和熟悉的 Python 行为放在一起比较。
一份代码,两条时间线
看懂 .ts 文件怎样变成可执行行为,以及为什么类型正确和程序正常是两回事。
def double(n: int) -> int:
return n * 2
print(double(4))
print(double("4"))function double(n: number): number {
return n * 2;
}
console.log(double(4));
console.log(double("4"));这里有两个变化。Python 的类型注解通常交给额外工具检查,Python 的字符串可以乘整数做重复。TypeScript 检查器会拒绝第二次调用;一旦对应 JavaScript 真的运行,JS 的 * 会把数字字符串转成数值。静态诊断与运算符语义各有自己的规则。
运行产物会打印 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 读取的是运行时值。
变量、值与作用域
把 Python 名称绑定的直觉迁移到 let、const、块作用域和 JS 基本类型。
items = ["read"]
alias = items
alias.append("write")
print(items)
if True:
status = "ready"
print(status)const items = ["read"];
const alias = items;
alias.push("write");
console.log(JSON.stringify(items));
if (true) {
const status = "ready";
console.log(status);
}const 固定变量的绑定,数组、对象内部仍可修改。let 允许重新赋值。let 和 const 属于最近的一层块,如果把示例里的 console.log(status) 移到块外,就无法访问这个局部变量。它们从进入作用域到初始化之间处于暂时性死区,提前读取会触发 ReferenceError。旧代码里的 var 通常是函数级作用域,声明前读到的可能是 undefined。
| Python 直觉 | TypeScript 写法 | 运行语义 |
|---|---|---|
str / f-string | string / `Hi ${name}` | 反引号支持插值和多行。JS 字符串以 UTF-16 码元计长度。 |
int、float | number / bigint | number 是双精度浮点数,整数安全范围有限。bigint 用 42n,可表示大整数,算术通常要保持两侧同种类型。 |
bool | boolean | 字面量 true、false,小写。 |
None | null、undefined | 缺失属性、未传入的参数常得到 undefined,null 常表示协议显式给出的空值。 |
唯一哨兵 object() | Symbol("tag") | 每次调用 Symbol() 得到新的身份,可作为对象的属性键。 |
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")));JS 的 % 求余跟随被除数的符号,Python 的整数 % 跟随除数的符号。Math.trunc 朝零截断,Math.floor 朝负无穷取整。[...s] 按 Unicode 码点迭代,组合字符与复杂 emoji 仍可能跨多个码点。
let retries = 3; // 推断为 number
const mode = "dry-run"; // 推断为字面量 "dry-run"
let stage: "new" | "done" = "new";
stage = "done";
console.log(retries, mode, stage);给局部变量一个初始值往往已经足够。显式注解更适合对外边界、空集合的元素、稍后赋值的变量,以及需要限制取值的字段。
控制流、比较与空值
用代码看清真假、相等、默认值以及循环的边界。
print(bool([]), bool({}))
print([1] == [1])
print("" or "fallback")
for index, item in enumerate(["a", "b"]):
print(index, item)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);
}条件使用 if (条件) { ... } else if (...) { ... } else { ... }。常见 falsy 值有 false、0、0n、""、NaN、null、undefined。判空数组用 items.length === 0,判空对象可用 Object.keys(obj).length === 0 检查可枚举的自有字符串键。=== 不做跨类型强制转换,对象则按身份比较。== 有转换规则,阅读时留意 x == null 常被用来同时匹配 null 与 undefined。
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);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时,它会原样保留。
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");for...of 迭代可迭代值。for...in 遍历对象可枚举的字符串属性名,可能包含原型上的属性;普通对象的自有键值对用 Object.entries(obj)。switch 匹配后会继续执行后续分支,除非遇到 break、return 或 throw。
自测 · 要保留重试次数 0,该选哪个运算符?
选 retries ?? 3。分别代入 0、undefined 和 null,结果是 0、3 和 3。
数组、对象、Map 与解构
用 Python 列表推导式和字典经验,迁移到常见的数据处理链。
names = [" Ada ", "", " Lin "]
cleaned = [s.strip() for s in names if s.strip()]
print(cleaned)
head, *tail = cleaned
print(head, tail)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));map 逐项变换,filter 按条件保留,find 返回第一个命中值或 undefined,some 判断是否存在。聚合时可写 nums.reduce((sum, n) => sum + n, 0),最后的初始值让空数组也得到确定结果。用 sort((a, b) => a - b) 做数值排序,默认排序按字符串比较。sort 会修改数组,[...nums].sort(...) 先复制外层。
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);对象展开的同名键由后写的属性覆盖。对象展开与数组展开都是浅复制。这里 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) 检查成员。对象按身份去重。 |
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]);常见陷阱是用 Record<string, Value> 后把任意键都当成存在。有限集合用 Record<"read" | "write", Value> 强制填齐已知键,动态查找可以用 Map.get、显式 Value | undefined 或开启索引访问检查。
函数、回调、闭包与 this
从 def、lambda 和闭包出发,读懂 TS 项目里大量出现的箭头函数与函数类型。
def label(name: str, *, prefix: str = "job") -> str:
return f"{prefix}:{name}"
print(label("read"))
print(label("read", prefix="task"))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" }));函数名后的一对括号是参数,最后一个冒号后的 string 是返回类型。这里第二个参数先用 = {} 处理整个参数缺失,再用 prefix = "job" 处理对象属性缺失。n?: number 允许省略参数,函数体中 n 可能为 undefined。...args: string[] 把剩余位置参数收成数组,调用侧的 fn(...args) 把数组展开成实参。
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: string) => string 在类型位置描述调用形状。value => value.toUpperCase() 在值位置创建函数,并借助上下文推断参数类型。箭头函数的表达式体自动返回结果,花括号块体需要 return。返回对象字面量时用 ({ value }) 包起来。
function counter(start = 0) {
let n = start;
return () => ++n;
}
const a = counter(10);
const b = counter();
console.log(a(), a(), b());两次调用 counter 创建独立的局部作用域。返回的函数仍引用各自的 n,因此能跨调用积累状态。普通函数和方法的 this 取决于调用方式。执行 obj.run() 时接收者是 obj,取出 const run = obj.run 后直接调用会丢失接收者。箭头函数从定义处捕获 this。
const job = {
name: "read",
run() { return this.name.toUpperCase(); },
};
const bound = job.run.bind(job);
const wrapped = () => job.run();
console.log(bound(), wrapped());重载描述调用约定,函数体负责实际分支
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 "));调用侧看到上面两条重载签名。第三条给实现使用。传入类型仍为 string | number 的变量时,两条公开重载可能都匹配不上。这个例子两种输入返回相同类型,也可以直接公开一个联合参数签名。void 作为回调返回类型时表示调用方会忽略结果,例如 forEach 接受返回值会被丢弃的回调。
对象形状、接口与类
分清形状约束、实例身份和可变状态。
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" }); // 多余属性检查只要变量的形状满足接口,它就可以传给 describe。可以把这想成由检查器提前确认的鸭子类型。直接传入新鲜对象字面量时,额外的属性检查常用于捕捉拼写错误。readonly 限制通过这个类型视图写入,约束不会递归,也不会冻结 JS 对象。Object.freeze 会在运行时冻结对象的外层。
retries?: number 表示属性可缺失,读取时可能得到 undefined。retries: number | undefined 要求键存在,值可为 undefined。开启 exactOptionalPropertyTypes 后,若希望可选属性还允许显式写入 undefined,要把它写进属性的值类型。
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);interface 可用 extends 扩展对象形状,同名接口在适用作用域可合并。type 还可为联合、元组、原始类型及类型计算起别名。A & B 表示同一个值要同时满足两种约束,遇到同名字段类型冲突可能得到 never。运行时合并对象由 {...a, ...b} 完成。
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())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);class 同时有类型和运行时值两面。new Counter(...) 产生对象,instanceof Counter 检查该构造函数的原型是否出现在对象的原型链上。implements 用于静态检查,接口不进入原型链。TS 的 private 主要由类型检查器约束,JS 的 #n 有运行时私有语义。读到 constructor(public name: string) {} 时,public 参数属性是 TS 简写,会在实例上创建并初始化属性。
类的普通方法由实例通过原型链找到,实例字段属于每个实例。extends Base 建立继承关系,派生构造器在使用 this 之前调用 super(...)。static 成员从类本身访问。abstract 声明由派生类完成的契约。
联合、收窄与穷尽检查
联合类型让状态差异进入类型系统,再用真实分支把类型范围缩小。
function normalize(id: string | number): string {
if (typeof id === "string") {
return id.trim();
}
return id.toString(10);
}
console.log(normalize(" abc "), normalize(42));联合 A | B 代表值可能属于任一成员。检查器只允许无条件访问所有成员共同支持的操作。typeof、instanceof、字面量比较、in 和 Array.isArray 都能提供收窄线索。记得 typeof null === "object",要处理可空对象时先显式排除 null。
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" }));never 表示在当前静态分析里没有可达的值,也可作为总会抛异常的函数返回类型。新增 { kind: "cancelled" } 后,若忘记补 case,默认分支仍可能收到 cancelled,便会在传给 unreachable 时得到编译诊断。用 kind、type 或 status 区分请求结果、事件、命令和状态,是阅读 TS 项目时值得优先识别的结构。
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" 常被拓宽成 string。satisfies Job 检查表达式满足目标约束,并保留表达式自己的推断结果。as const 让字面量保留精确值,将对象字面量属性标为只读,并把数组字面量推断为只读元组。两者在运行时都被擦除。
自测 · 为什么 if (value) 可能把合法输入排除?
如果 value 的类型是 number | undefined,0 是合法数值但在真假判断中为假。用 value !== undefined 表达检查的真实目的。
泛型保留输入与输出的关系
把 T 看成由调用决定的类型参数。像 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([]))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);first<T> 宣告了类型参数。readonly T[] 允许传入可变或只读数组,函数体只能通过这个引用读取。T | undefined 表达空数组的运行结果。若改成 any[] → any,调用后就失去了元素类型的线索。
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 中这里的 extends 表示类型约束。第一步推断 T = { name: string; retries: number },第二步得到 keyof T = "name" | "retries",第三步用传入的键决定 K,最后 T[K] 读出对应属性的类型。keyof 计算类型层的键,Object.keys 才会枚举某个实际对象的键。
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);
}E = string 给类型参数默认值。只传 Outcome<number> 时错误载荷为字符串,也可以在另一个接口里写 Outcome<User, Error>。泛型从实参推断或由调用方提供,执行函数时没有一个名叫 T 的运行时参数。想按类别构造实例时,需要传构造函数或工厂等运行时值。
类型变换与推断
顺着符号从里向外读,弄懂项目里常见的 keyof、typeof、映射类型与 infer。
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"));typeof actions在类型位置获取变量的静态类型,这里是readonly ["read", "write"]。表达式里的typeof actions则在运行时得到"object"。[number]获取数字索引可读出的元素类型,即"read" | "write"。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> | 读出返回类型 / 展开等待后的值类型 | 从异步函数签名导出数据模型 |
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);[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。
外部数据、断言与异常
给来自文件、命令行或网络的值安排明确的运行时检查。
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));
}
}JSON.parse 的库签名返回 any,这里主动接到 unknown,避免不可信值绕过检查。value is Job 告诉检查器:守卫为真时可以按 Job 读字段。守卫本身的判断由作者负责,漏掉 retries 的检查也可能照样编译。处理外部输入时可检查类型之外的业务约束,例如次数非负和路径非空。
| 语法 | 影响 | 阅读时的问题 |
|---|---|---|
any | 允许许多未检查的操作,结果还能继续传播为 any | 数据来自哪里,在哪里获得保证? |
unknown | 可接收任意值,使用前要收窄 | 检查覆盖了哪些字段或分支? |
value as Job | 可接受的断言改变静态视图 | 运行时是否有证据支持这一形状? |
value! | 在类型上排除 null 和 undefined | 哪个前置条件保证了值存在? |
String(value) / Number(value) | 运行时转换 | 是否要拒绝 NaN、空字符串等特殊结果? |
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);JS 可以抛出任何值,catch 里先用 instanceof Error 收窄,再取 message。finally 在成功、异常或提前返回时都会执行,适合释放资源。可预期且需要调用者分类处理的失败,也可显式返回第 14 章那样的 Outcome。
import、export 与依赖解析
把 Python 的 import 经验迁移到 Node.js,同时建立 ESM 和 CommonJS 的概念。
# task.py
def run(name: str) -> str:
return name.upper()
# main.py
from task import run
print(run("read"))// 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));右侧是两个文件。以第 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,先查产物路径、文件扩展名、依赖是否安装和包导出规则。
Promise、async/await 与事件循环
学会预测输出顺序,理解等待 I/O 时函数和运行时分别在做什么。
import asyncio
async def work():
print("work")
return 7
async def main():
pending = work()
print("after call")
print(await pending)
asyncio.run(main())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();调用 JS 的 async 函数会同步执行到首次挂起或完成,同时给调用者一个 Promise。return 7 让 Promise 兑现为 7。throw 让 Promise 拒绝。await 会暂停当前 async 函数后续的执行,即使等待的值已兑现,续段也会稍后调度。调用者和事件循环可以继续处理别的工作。示例中的 void main() 丢弃返回值,真实入口仍需处理可能的拒绝,例如 main().catch(report)。
console.log("A: sync start");
setTimeout(() => console.log("D: timer"), 0);
Promise.resolve().then(() => console.log("C: promise"));
console.log("B: sync end");process.nextTick 或其他事件循环阶段。同步函数通过调用栈执行。网络和文件 I/O 由宿主协调,完成后相关回调或 Promise 续段才有机会运行。同步的长循环仍会占用 JS 执行线程。setTimeout(fn, 0) 代表尽早调度,运行顺序还受当前任务和队列影响。
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();两个 delayed 在构造数组时就启动。Promise.all 兑现后的数组保持输入顺序,任一个拒绝会使组合 Promise 拒绝,其他操作仍会继续,除非另外取消。要收集成功与失败两类结果,用 Promise.allSettled。异步有先后依赖时按次序 await;独立操作可先发起再一起等待。需要逐项等待时用 for...of 加 await,forEach(async ...) 会丢弃回调返回的 Promise。
自测 · 两次 await 写在循环里,会发生什么?
若每次迭代先创建一个 Promise 再等待它,第二次操作要等第一次完成才启动。若循环前已把两项操作都启动,随后逐项等待只决定消费顺序。
异步迭代、流式事件与取消
读懂一串分批到达的值,以及调用方如何通知底层操作停止。
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();和 Python 的异步生成器与 async for 类似,async function* 返回可异步迭代的生成器。每次请求下一项都会得到一个 Promise,兑现为迭代结果对象,形如 { value, done }。for await...of 替你等待、取值并判断结束。这里的业务事件 { kind: "done" } 和迭代器自身的 done: true 属于两层协议。提前 break 会在可用时调用并等待迭代器的 return(),生成器可在 finally 中清理资源。
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;按第 21 章的 ESM 配置运行这段代码,顶层 await 在模块中有效。取消是一种合作机制。调用 abort() 后,信号被标为已取消,支持该信号的 API 会按各自的契约作出响应。自己实现长操作时,要在合适的边界检查 signal.aborted 或监听 abort。如果函数接收了信号却没有继续传给实际工作,底层 I/O 仍可能继续。
tsc、运行器与调试
从启动脚本追到真正执行的文件,区分检查、转译、打包和宿主执行。
npm 负责依赖与脚本,tsc 负责检查和产物,node 负责执行。| 设置 / 文件 | 解决什么问题 |
|---|---|
package.json | 项目脚本、依赖、JS 模块类型和包元数据。package-lock.json 记录依赖解析结果。 |
strict | 打开一组严格检查,例如空值处理和隐式 any。noUncheckedIndexedAccess 与 exactOptionalPropertyTypes 是另行选择的检查。 |
target / lib | target 影响输出的 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 的导入后缀要指向运行时实际可加载的文件。
const names: string[] = [];
const first = names[0];
console.log(first.toUpperCase());调试时先辨认错误所在的层。TS... 诊断看类型、库声明和编译配置。ERR_MODULE_NOT_FOUND 看入口、依赖和文件后缀。TypeError 看真实数据与调用堆栈。若代码刚改过,先确认执行命令指向本次成功构建的产物。node --enable-source-maps dist/main.js 可以让堆栈定位到原始 TS 行。
实战 · 一个任务命令行工具
从空目录搭项目,把运行时校验、判别联合、异步文件读取和 ESM 接在一起。
这个 CLI 从 JSON 文件读任务,接受 count 和 echo 两种命令。以下用 Node.js 24、npm 与 TypeScript 5.9。Windows PowerShell 可以直接使用下面的 npm.cmd,macOS/Linux 使用 npm。在自己选的练习位置执行,接下来把各代码框保存为标出的文件。
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 中保留 devDependencies,添加 "type": "module" 并更新 scripts。下面给出一个完整文件形状,也可直接替换。
{
"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"
}
}如果替换了整个 package.json,运行一次 npm.cmd install 同步锁文件。这里把类型包作为开发依赖,因为执行产物时只用 Node 内置模块。
{
"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"]
}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);
}
}parseTask 接收的是 JSON 解码后的值。JSON 数组没有 JavaScript 稀疏数组的空洞,所以这里用 every 检查每项。若把守卫复用于接受任意 JS 数组的公共接口,要考虑 every 会跳过空洞。execute 接收已经验证过的 Task,可以根据标签直接访问对应字段。
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;
});{"kind":"count","items":["read","write","test"]}{"kind":"count","items":[1]}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.cmd start -- task.json。-- 之后的参数传给脚本。相对文件路径按当前工作目录解析,示例命令在练习项目根目录执行。process.argv[0] 是 Node 可执行文件路径,[1] 是入口脚本路径,[2] 才是第一项业务参数。
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 项目
- 从
package.json的scripts找实际入口,再看tsconfig.json的模块模式、检查设置与输出目录。 - 从入口跟到参数、文件或网络输入,标记值第一次被校验的位置。遇到
as或!,向前寻找约束成立的证据。 - 先读联合类型的标签与分支,再读各分支的字段。遇到泛型,代入一个具体调用值,把
T、K等依次写下来。 - 每遇到
await、回调或yield,记下操作何时启动、结果怎么传递、失败和取消由谁处理。 - 选一个具体输入,预测控制台输出和退出码,再运行验证。
按主题查官方文档
- 基础、类型擦除、编译诊断。TypeScript Handbook · The Basics
- 控制流、判别联合、穷尽检查。TypeScript Handbook · Narrowing
- 泛型、映射类型、条件类型。TypeScript Handbook · Creating Types from Types
- ESM、CommonJS 与编译选项。TypeScript · Modules Reference
- Node 原生类型擦除及其限制。Node.js · TypeScript
- 调用栈、队列与 Promise 续段。MDN · JavaScript execution model