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:

两个世界TypeScript → JavaScript
// —— 你写的 .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 知识

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。直接后果:浮点误差对所有数字生效。

一个 numberTypeScript
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 = 所有值的集合(全集) string 所有字符串 "hi" number 所有数字 never = ∅(空集,没有任何值)
图 1.1把类型看成值的集合。注意:unknown 是装下一切的全集——任何值都能赋给它,但反过来它不能赋给任何具体类型(先得收窄);never 是空集;字面量 "hi" 是只有一个元素的集合;「A 能赋给 B」等价于「集合 A ⊆ 集合 B」。
  • unknown(安全的顶类型):全集。任何值都能存进 unknown,但取出来用之前必须先收窄(见 §1.8)。它是 Java Object 的精神对应——能装一切,用前要确认。
  • any(逃生舱):它不是「集合」,而是「关掉类型检查」的开关。标了 any 的值,TS 对它的一切操作都不再检查。它不等于 Java 的 Object——Object 还要求你强转,any 连这一步都免了。
  • never(底类型):空集。没有任何值属于它。用于表达「永远到不了这里」——比如一个永远抛异常、永不正常返回的函数,返回类型就是 never。
unknown(安全顶类型) unknown 任意值 ✗ 用前必须收窄 any(逃生舱) any 进出都不拦,检查关闭 代价:错误静默漏到运行时
图 1.2unknown 与 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 自己推。

注解在边界,推断在内部TypeScript
// 边界:参数与返回值显式注解(契约)
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 与 typeTypeScript
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 };
}

两者在「描述对象形状」上几乎可互换。差异在能力范围:

表 1.1 · interface vs type 的取舍
维度interfacetype(类型别名)
能描述什么只能是对象 / 类的形状任何类型:联合、元组、原始类型别名、映射类型
扩展extends 继承& 交叉类型
同名声明自动合并(declaration merging)报「重复标识符」错误
典型用法对象形状、类契约、对外 API联合 / 元组 / 工具类型 / 复杂组合

经验法则:对象形状和类契约用 interface,联合 / 元组 / 需要类型运算的用 type。团队里选一个为主、保持一致比纠结哪个「更好」重要。

陷阱 · Java 直觉失效

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 版的枚举」——一组允许的具体值:

字面量联合 = JS 风格的枚举TypeScript
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 完全不同。

函数类型TypeScript
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 不同。最常见的翻车是把对象方法当回调传出去:

this 在回调里丢失TypeScript
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
陷阱 · Java 直觉失效

c.increment 单独取出来传给 forEach 时,它和对象 c 的联系断了;调用时 this 不再是 c(严格模式下是 undefined),this.count++ 抛错。修复:用箭头函数(词法绑定 this)或 c.increment.bind(c)。Java 的「方法永远绑定在实例上」直觉,在这里必须放下。

与下一节的关系:函数要适配多种类型而不退化成 any,靠的是泛型——一个你在 Java 已经熟悉的工具。

1.7泛型

泛型让类型像参数一样传进来,写一份逻辑适配多种类型,同时保住类型安全。

为什么需要它

和 Java 同一个动机:避免为 string[]、number[] 各写一遍取首元素的函数,又不想退化成 any[] 丢掉类型。泛型把「元素类型」抽成参数 T。

泛型函数与约束TypeScript
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——这叫控制流分析。

value: string | number typeof value === "string" ? 真 value: string .toUpperCase() 假 value: number .toFixed(2)
图 1.3typeof 守卫把联合类型在两个分支里分别收窄。注意:你只写了一个普通的 JS if,TS 在每个分支里自动把 value 当成对应的具体类型——不需要 Java 那样的显式强转。
四种收窄手段TypeScript
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 有效

instanceof 检查的是「运行时的构造函数 / 原型」,所以只能用于 class。对 interface 或 type 用 instanceof 会直接编译报错——因为它们运行时不存在(又是擦除)。区分两个 interface 形状的对象,得用 in 或带标签的联合,而不是 instanceof。

本章收束:八个零件背后是同一条线——类型只在编译期、运行时被擦除。number 只有一个、instanceof 不能用于 interface、泛型运行时拿不到 T、收窄要靠值层面的检查……全是这条线的推论。下一章把这条线本身讲透:它为什么这么设计,代价是什么。

§本章 self-check

先合上教程,把答案写在纸上或编辑器里。写完再展开对照——直接点开等于把这一节当再读一遍。

  1. 一段 TS 编译成 JS 后,类型注解(: number、interface)去哪了?这对「运行时能否拿到类型」意味着什么?
  2. let x = 5 和 const x = 5 各被推断成什么类型?为什么不同?
  3. any 和 unknown 都能接收任何值,最关键的区别是什么?哪个适合接 JSON.parse 的结果?
  4. (设计层)为什么 instanceof 能用于 class,却不能用于 interface?
答案(先做完再展开)
  1. 被完全擦除,产物里一行类型都不剩。意味着运行时拿不到任何类型信息——不能反射类型、不能对 interface 用 instanceof、泛型拿不到 T;外部输入必须自己用值层面的检查校验。
  2. let x = 5 → number(可再赋值,类型被拓宽);const x = 5 → 字面量类型 5(不可变,收到最窄)。直接影响能否把它传进只接受字面量联合的参数。
  3. any 关闭检查、可直接 .foo,错误漏到运行时;unknown 安全,用前必须收窄。接 JSON.parse(外部、形状不确定)应该用 unknown。
  4. 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 会回到它。