Chapter 01
核心概念:类型系统的零件
导览页给了一张「两个世界」的概念地图——编译期的类型世界、运行时的值世界。这一章逐个拆解左框里的零件:每个零件先给定义和它解决的问题,再用「对照 Java」校准你的直觉,标出能照搬的和会害你的地方。
本章你将建立的 schema
- 「类型只在编译期、运行时被擦除」这条主线,能套到每一个后续概念上
- 把「类型」理解成「值的集合」——assignability 就是子集关系
- 哪些 Java 直觉能照搬(泛型、接口的用途),哪些会翻车(一个 number、两个空值、this、instanceof)
- 读到一段类型声明时,能说出它在描述什么形状、运行时还剩什么
1.1两个世界:编译期类型 vs 运行时值
TypeScript 在 JavaScript 上加了一层只在编译期存在的类型;类型检查一结束,类型就被擦掉,真正运行的还是纯 JS。
纯 JS 的类型错误只在运行时炸出来:字段名拼错、函数少传一个参数、把字符串当数字用——这些都要等代码跑到那一行才报。TypeScript 把这类错误提前到「你按下保存、还没运行」时就在编辑器里标红。代价写在定义里:它只在编译期帮你,运行时它什么都不剩。
TS 的编译期检查像 Java 的编译期类型检查。但类比在一个点上断裂:Java 的类型运行时还在——你能反射、能 getClass()、能对接口 instanceof。TS 的类型运行时彻底消失。这条差异是后面一半「怪事」的根源。
看一段最小的 TS,和它编译出的 JS:
// —— 你写的 .ts(类型世界 + 值世界都在)——
let count: number = 5;
function greet(name: string): string {
return "Hi, " + name;
}
// —— tsc 编译后的 .js(类型世界被擦干净)——
// let count = 5;
// function greet(name) {
// return "Hi, " + name;
// }
注解 : number、: string 在产物里一个不剩。这就是「擦除」最直观的样子:类型是给 tsc 和编辑器看的批注,不是会被打包进运行结果的代码。
Java 其实已经让你见过擦除:List<String> 在运行时只是 List,泛型参数 String 被擦掉了。TypeScript 把这件事推到极致——不只泛型参数,所有类型注解都像 Java 的泛型参数一样被擦除。你对「Java 泛型运行时拿不到 T」的那份接受,正好是理解整个 TS 类型系统的起点。
一个 function add(a: number, b: number),运行时还能不能拿到「这两个参数本该是 number」这条信息,从而在别人传字符串时自动拦下?
展开答案(先停 10 秒)
不能。编译后就是 function add(a, b),类型信息没了。运行时若有人 add("1", "2"),JS 照跑不误,得到 "12"(字符串拼接)。想在运行时拦住,只能自己写 typeof a === "number" 这类值层面的检查。
这指向一条贯穿全书的原理:类型保证只在编译期成立;运行时的输入(用户、API、文件)不受类型系统保护,必须自己校验。
与下一节的关系:既然类型是编译期的批注,那这些批注能写出哪些「值的集合」?下一节从最基础的类型零件开始。
1.2基础类型:一个 number、两个空值、any/unknown/never
TS 的原始类型把「这个值属于哪一类」写成类型;其中几个的边界和 Java 差得最远,最容易栽。
类型系统从原始类型起步。多数能直接对应 Java,但数字、空值、顶/底类型这三处和 Java 的差异,是 Java 工程师第一周最常踩的失败模式——不先讲清,后面全是连锁误判。
一个 number:没有 int / long / double 之分
JS 只有一种数字类型,TS 对应只有一个 number,底层是 IEEE 754 双精度浮点。没有 int、long、float 的区分;超大整数才用单独的 bigint。直接后果:浮点误差对所有数字生效。
let price: number = 10; // 整数也是 number
let ratio: number = 0.5; // 小数也是 number
console.log(0.1 + 0.2); // 0.30000000000000004,不是 0.3
console.log(0.1 + 0.2 === 0.3); // false
const big: bigint = 9007199254740993n; // 超出安全整数范围才用 bigint
Java 里 10 / 3 是整数除法得 3;TS 里 10 / 3 是 3.333...,因为没有整数类型。还有:数组越界 arr[99] 返回 undefined 而不是抛异常。Java 的「会抛 IndexOutOfBoundsException」直觉在这里失效。
两个空值:null 和 undefined
Java 只有一个 null。JS/TS 有两个表示「空」的值,含义不同:
undefined——「没赋值 / 不存在」。变量声明未赋值、对象没有的属性、函数没 return,都是undefined。多数是「自然发生」的空。null——「显式的空」。通常是程序员主动赋的,表示「这里就是要为空」。
实务上一个常见误判:用 x === null 检查空值,却漏掉 undefined。两者不相等(null === undefined 是 false)。
顶与底:unknown / any / never
这三个最抽象,用「类型 = 值的集合」来理解最快。一个类型就是「所有能赋给它的值」组成的集合;一个值能不能赋给某类型,等价于问「它在不在那个集合里」。
unknown 是装下一切的全集——任何值都能赋给它,但反过来它不能赋给任何具体类型(先得收窄);never 是空集;字面量 "hi" 是只有一个元素的集合;「A 能赋给 B」等价于「集合 A ⊆ 集合 B」。unknown(安全的顶类型):全集。任何值都能存进unknown,但取出来用之前必须先收窄(见 §1.8)。它是 JavaObject的精神对应——能装一切,用前要确认。any(逃生舱):它不是「集合」,而是「关掉类型检查」的开关。标了any的值,TS 对它的一切操作都不再检查。它不等于 Java 的Object——Object还要求你强转,any连这一步都免了。never(底类型):空集。没有任何值属于它。用于表达「永远到不了这里」——比如一个永远抛异常、永不正常返回的函数,返回类型就是never。
unknown 与 any 都能接收任何值,区别在「出口」。注意:unknown 像单向膜——进得来,但用之前编译器逼你收窄,安全;any 把检查整个关掉,方便但错误会一路漏到运行时。优先用 unknown。有个值 const data: unknown = JSON.parse(raw)。直接写 data.name 会怎样?换成 const data: any = ... 又会怎样?
展开答案(先停 10 秒)
unknown 版:data.name 直接编译报错——「对象类型为 unknown」。必须先收窄,例如 if (data && typeof data === "object" && "name" in data)。
any 版:data.name 编译通过,但如果 data 其实没有 name,运行时得到 undefined,错误被推迟、被掩盖。这正是为什么处理「外部来的、形状不确定的数据」要用 unknown 而不是 any。
与下一节的关系:知道有哪些类型后,下一个问题是——这些类型该自己手写,还是让 TS 替你推出来?
1.3类型注解与类型推断
注解是你手写给值的类型;推断是 TS 根据值自动算出的类型。能推断的就别手写。
JS 本身没类型。注解把「这里期望什么」写给编译器和读代码的人。但到处写注解既啰嗦又容易和实际值不同步——TS 的推断很强,绝大多数局部变量不用标。关键是知道哪里必须手写。
经验法则:在边界处手写注解,内部交给推断。边界 = 函数参数、函数返回值、模块对外的导出、外部数据的入口。这些地方写清楚,等于给整个系统钉下契约;内部的临时变量让 TS 自己推。
// 边界:参数与返回值显式注解(契约)
function totalPrice(items: { price: number }[]): number {
let sum = 0; // 内部:推断为 number,不用标
for (const it of items) sum += it.price;
return sum;
}
let name = "Ada"; // 推断为 string
let ids = [1, 2, 3]; // 推断为 number[]
Java 10 的 var 是局部变量推断,和 TS 的 let name = "Ada" 同理。区别是 TS 的推断更广——连函数返回值都能推。但「对外契约要显式」这条工程纪律两边一致:库的公开 API 别依赖推断,手写出来更稳。
let x = 5 和 const x = 5,TS 推断出的类型一样吗?
展开答案(先停 10 秒)
不一样。let x = 5 推断为 number——因为 let 之后还能改成别的数字,TS 把类型「拓宽」到 number。const x = 5 推断为字面量类型 5——const 不能再赋值,值永远是 5,所以收到最窄。
这解释了一个常见困惑:把变量传进只接受字面量联合的函数时,const 的常量能过、let 的变量被拒(已拓宽成 number/string)。这条会在 §1.5 再次出现。
与下一节的关系:原始类型之上,真实程序到处是对象。怎么给对象的「形状」命名?
1.4对象类型:interface 与 type
用 interface 或 type 给对象的「形状」(有哪些属性、各是什么类型)起个名字。
对象是 JS 的主角。给形状命名后,函数才能声明「需要一个长这样的对象」,编辑器才能补全、检查。没有它,对象就是一团 any。
interface User {
id: number;
name: string;
email?: string; // ? 表示可选属性
readonly createdAt: number; // readonly:初始化后不可改
}
type Point = { x: number; y: number }; // type 也能描述对象形状
function rename(u: User, next: string): User {
return { ...u, name: next };
}
两者在「描述对象形状」上几乎可互换。差异在能力范围:
| 维度 | interface | type(类型别名) |
|---|---|---|
| 能描述什么 | 只能是对象 / 类的形状 | 任何类型:联合、元组、原始类型别名、映射类型 |
| 扩展 | extends 继承 | & 交叉类型 |
| 同名声明 | 自动合并(declaration merging) | 报「重复标识符」错误 |
| 典型用法 | 对象形状、类契约、对外 API | 联合 / 元组 / 工具类型 / 复杂组合 |
经验法则:对象形状和类契约用 interface,联合 / 元组 / 需要类型运算的用 type。团队里选一个为主、保持一致比纠结哪个「更好」重要。
Java 的 interface 是名义契约:一个类必须 implements User 才算是 User。TS 的 interface 是结构形状:任何对象只要长得对(有 id: number 和 name: string),就自动算 User,不需要、也没有 implements 这一步。这是第 2 章「结构化类型」的核心,先记住这个反直觉点。
先后写 interface Box { w: number } 和 interface Box { h: number },会报「重复定义」吗?换成两个同名 type Box 呢?
展开答案(先停 10 秒)
interface 版:不报错,两次声明合并成 { w: number; h: number }——这叫声明合并,是 Java 没有的机制(用来给第三方类型「补充」属性)。type 版:报错,「标识符 Box 重复」。这是表 1.1 里「同名声明」那行的直接后果。
与下一节的关系:对象形状之外,JS 函数常接受「几种值之一」。表达「或」要用联合类型。
1.5联合类型与字面量类型
联合 A | B 表示「A 或 B」;字面量类型把一个具体的值("GET"、200)本身当成类型。
JS 函数天生灵活:一个参数既可以是字符串、也可以是数字,一个配置项只允许几个固定字符串。联合 + 字面量能精确表达这些,而这正是 JS 代码的日常形状。
把字面量用联合串起来,就得到「JS 版的枚举」——一组允许的具体值:
type Method = "GET" | "POST" | "PUT" | "DELETE";
function request(url: string, method: Method): void {
// ...
}
request("/users", "POST"); // ✓
request("/users", "PATCH"); // ✗ 编译报错:"PATCH" 不在联合里
type Id = string | number; // 联合不同原始类型也很常见
Java 这种场景用 enum Method { GET, POST }。TS 里首选字面量联合,因为:① 它是纯类型,编译后零运行时开销(被擦除);② 它的值就是字符串本身,和 JSON / HTTP 头 / API 天然兼容,不用做 enum↔字符串转换。TS 也有 enum 关键字,但它有运行时陷阱——第 2 章 §2.3 专门拆。
把 "POST" 存进 let m = "POST" 再 request("/x", m),能编译过吗?换成 const m = "POST" 呢?
展开答案(先停 10 秒)
let m = "POST":报错。let 把 m 推断成 string(§1.3 的拓宽),而 string 比 Method 宽,不能塞进只接受四个字面量的参数。const m = "POST":通过,const 推断成字面量类型 "POST",正好属于联合。这就是 §1.3 那条推断规则的实战后果。
与下一节的关系:值和对象之外,函数本身也有类型;而函数里藏着 JS 最坑人的运行时机制——this。
1.6函数类型与 this
函数有参数类型和返回类型;而 JS 的 this 在「被怎么调用」时才确定,不是在定义处词法绑定的。
函数是 JS 的一等公民,到处被当值传递。给它标类型保证调用方传对参数、用对返回值。this 则是 Java 工程师最容易栽的运行时陷阱——它的行为和 Java 完全不同。
function send(url: string, retries: number = 3, ...tags: string[]): boolean {
return true;
}
// retries 有默认值;...tags 是剩余参数(收成 string[])
// 函数也能作为类型标注(回调)
function onClick(handler: (event: string) => void): void {
handler("tap");
}
this 的陷阱
Java 里 this 永远指当前实例,编译期就定死。JS 里 this 取决于调用方式——同一个函数,不同调法,this 不同。最常见的翻车是把对象方法当回调传出去:
class Counter {
count = 0;
increment() { this.count++; } // 普通方法
incrementSafe = () => { this.count++; }; // 箭头函数:锁定词法 this
}
const c = new Counter();
[1, 2, 3].forEach(c.increment); // ✗ this 变成 undefined,运行时报错
[1, 2, 3].forEach(c.incrementSafe); // ✓ 箭头函数保留了 c 作为 this
c.increment 单独取出来传给 forEach 时,它和对象 c 的联系断了;调用时 this 不再是 c(严格模式下是 undefined),this.count++ 抛错。修复:用箭头函数(词法绑定 this)或 c.increment.bind(c)。Java 的「方法永远绑定在实例上」直觉,在这里必须放下。
与下一节的关系:函数要适配多种类型而不退化成 any,靠的是泛型——一个你在 Java 已经熟悉的工具。
1.7泛型
泛型让类型像参数一样传进来,写一份逻辑适配多种类型,同时保住类型安全。
和 Java 同一个动机:避免为 string[]、number[] 各写一遍取首元素的函数,又不想退化成 any[] 丢掉类型。泛型把「元素类型」抽成参数 T。
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const a = first([1, 2, 3]); // a: number | undefined
const b = first(["x", "y"]); // b: string | undefined
// 约束:T 必须有 id 字段
function byId<T extends { id: number }>(items: T[], id: number): T | undefined {
return items.find(it => it.id === id);
}
语法和 Java 几乎一样(<T>、T extends ...)。最重要的相同点:Java 泛型运行时被擦除,TS 也擦除——你早就接受了「运行时拿不到 T」。差异是程度:Java 至少保留原始类型 List;TS 连这个都没有,运行时 arr 就是个普通数组,T 不留一丝痕迹。所以 new T()、T.class 在两边都不行,TS 更彻底。
能不能在 first<T> 里写 if (x instanceof T) 来判断元素类型?
展开答案(先停 10 秒)
不能。T 是编译期的类型参数,运行时已被擦除,根本不存在一个叫 T 的东西供 instanceof 用——这和 Java 不能写 x instanceof T 是同一道限制。要在运行时辨别,只能基于值本身(typeof、检查某个字段在不在),也就是下一节的收窄。
与下一节的关系:类型擦除后运行时没有类型信息,那怎么在运行时区分「这到底是 string 还是 number」?答案是收窄——连接两个世界的桥。
1.8收窄 narrowing
用 JS 在运行时能做的检查(typeof / instanceof / in / 比较字面量),让 TS 把宽类型「收窄」成具体类型。
有了联合类型(string | number),用之前得知道具体是哪个。但类型已被擦除,运行时只能靠值本身的检查来辨别。收窄就是那座桥:你用值世界的检查(typeof x === "string"),TS 在类型世界里同步把 x 收窄成 string——这叫控制流分析。
typeof 守卫把联合类型在两个分支里分别收窄。注意:你只写了一个普通的 JS if,TS 在每个分支里自动把 value 当成对应的具体类型——不需要 Java 那样的显式强转。function format(value: string | number): string {
if (typeof value === "string") {
return value.toUpperCase(); // 这一支里 value 是 string
}
return value.toFixed(2); // 另一支里 value 是 number
}
// in:靠属性是否存在来收窄
type Dog = { bark: () => void };
type Cat = { meow: () => void };
function speak(a: Dog | Cat) {
if ("bark" in a) a.bark(); else a.meow();
}
// 带标签的联合(discriminated union)——最稳的收窄
type Result =
| { kind: "ok"; data: string }
| { kind: "err"; message: string };
function handle(r: Result) {
if (r.kind === "ok") console.log(r.data);
else console.log(r.message);
}
instanceof 检查的是「运行时的构造函数 / 原型」,所以只能用于 class。对 interface 或 type 用 instanceof 会直接编译报错——因为它们运行时不存在(又是擦除)。区分两个 interface 形状的对象,得用 in 或带标签的联合,而不是 instanceof。
本章收束:八个零件背后是同一条线——类型只在编译期、运行时被擦除。number 只有一个、instanceof 不能用于 interface、泛型运行时拿不到 T、收窄要靠值层面的检查……全是这条线的推论。下一章把这条线本身讲透:它为什么这么设计,代价是什么。
§本章 self-check
先合上教程,把答案写在纸上或编辑器里。写完再展开对照——直接点开等于把这一节当再读一遍。
- 一段 TS 编译成 JS 后,类型注解(
: number、interface)去哪了?这对「运行时能否拿到类型」意味着什么? let x = 5和const x = 5各被推断成什么类型?为什么不同?any和unknown都能接收任何值,最关键的区别是什么?哪个适合接JSON.parse的结果?- (设计层)为什么
instanceof能用于 class,却不能用于 interface?
答案(先做完再展开)
- 被完全擦除,产物里一行类型都不剩。意味着运行时拿不到任何类型信息——不能反射类型、不能对 interface 用
instanceof、泛型拿不到T;外部输入必须自己用值层面的检查校验。 let x = 5→number(可再赋值,类型被拓宽);const x = 5→ 字面量类型5(不可变,收到最窄)。直接影响能否把它传进只接受字面量联合的参数。any关闭检查、可直接.foo,错误漏到运行时;unknown安全,用前必须收窄。接JSON.parse(外部、形状不确定)应该用unknown。instanceof依赖运行时的构造函数 / 原型链,class 编译后会留下真实的构造函数;而 interface / type 是纯类型,运行时被擦除、根本不存在,没有东西供instanceof检查。
不用 as,安全地把 unknown 收窄成具体形状
写一个 parseConfig(raw: unknown): { port: number } | null:当 raw 确实是一个含数字 port 的对象时返回它,否则返回 null。约束:不许用 as 断言,只能靠运行时检查让 TS 自己收窄。
提示(卡住再展开)
分层检查:先 raw !== null && typeof raw === "object",再 "port" in raw,最后 typeof (raw as ...).port——等等,目标是不用 as。试试在每层之后让 TS 自动收窄:"port" in raw 通过后,再单独取出 raw.port 用 typeof 判断。你会发现:这套手写校验,正是 zod 这类库替你做的事——记住这个手感,第 2 章 §2.2 会回到它。