Skip to content
核心概念

核心概念

本章建立 TypeScript 最底层的心理模型:类型系统不是"给 JS 加类型"这么简单,而是一套完整的设计哲学——结构类型、类型推断、兼容性规则——理解这些,才能真正理解 TypeScript 为什么会这样行为。


TypeScript 的设计哲学

TypeScript 的官方定义极其克制:“TypeScript is JavaScript with syntax for types.” 它不试图重新发明 JavaScript,而是在 JavaScript 之上叠加一层可选的、编译时擦除的类型标注。这一设计哲学贯穿 TypeScript 的所有决策:

  • 渐进式采纳(Gradual Typing):你可以从 .js 逐步迁移到 .ts,按文件、按变量逐步增加类型覆盖率,不必一次性全部标注。
  • 编译时擦除:所有类型标注在编译为 JavaScript 时被完全移除——类型系统没有运行时开销,也不会改变程序的运行时行为。
  • 目标不是完美:TypeScript 的类型系统有意做出了"妥协"——例如 any 的存在、结构类型而非名义类型——这些不是缺陷,而是为了让 JavaScript 生态能够无缝接入。

🔬 深入原理:TypeScript 的类型系统是"结构类型系统"(Structural Type System),也称为"鸭子类型"(Duck Typing)。核心原则:“如果它走起来像鸭子,叫起来像鸭子,那它就是鸭子。” 类型检查器只关心一个值是否拥有期望的形状(shape),而不关心它叫什么名字。这与 Java、C#、Go 等语言的"名义类型系统"(Nominal Type System)有根本区别——在名义类型系统中,即使两个类型的所有字段一模一样,只要名字不同,它们就是不同的类型。

TypeScript 的编译器架构分为两层:

  1. 类型检查层(编译时):负责推断类型、检查赋值是否符合结构兼容、报告类型错误。这一层完全不存在于运行时。
  2. 代码生成层(编译时 + 运行时):负责将 TS 语法降级为目标 ES 版本(如将 ES 模块语法转为 CommonJS、转换装饰器等)。

两层是解耦的——即使代码有类型错误,tsc 仍然可以生成 JavaScript(由 noEmitOnError 控制)。


结构类型 vs 名义类型

这是理解 TypeScript 类型兼容性的最关键模型。在结构类型系统中,两个类型是否兼容,仅取决于它们是否拥有相同的结构(属性名 + 属性类型),与类型名称无关。

interface Point {
  x: number
  y: number
}

interface Vector {
  x: number
  y: number
}

const point: Point = { x: 0, y: 0 }
const vector: Vector = point // ✅ 完全合法!结构相同,类型即兼容

如果在 Java 或 C# 中编写等价代码,上述赋值会报编译错误——PointVector 是不同名称的类,即使字段完全相同也不可互换。

结构类型的优势在于与 JavaScript 的动态本性高度契合:JSON 解析得到的对象、第三方库定义的接口、你自己声明的类型——只要形状对得上,就可以无缝协作,不需要显式声明 implements 或编写适配器。

// 函数期望任何拥有 name 属性的对象
function greet(entity: { name: string }) {
  console.log(`Hello, ${entity.name}`)
}

// 以下所有类型都可以传入——不需要 implements 声明
interface User { name: string; age: number }
interface Group { name: string; members: number }
class Organization { name: string = "" }

greet({ name: "Willow" })        // ✅ 对象字面量
greet({ name: "Admin", age: 18 }) // ✅ 多出来的属性不影响

💡 最佳实践:理解结构类型制度,是正确使用 TypeScript 的关键。不要执着于类型名称,而要关注类型形状是否匹配。当你遇到"两个接口看起来一样但类型不兼容"的困惑时,换一个角度想——是不是形状真的不一样?是不是可选属性的差异?是不是 readonly 修饰符?


类型推断

TypeScript 拥有强大的类型推断引擎——绝大多数情况下,你不需要手动写出类型,编译器会从值和上下文中自动推导。

变量声明推断

let name = "Willow"      // typeof name: string  (let 声明,类型拓宽)
const name2 = "Willow"   // typeof name2: "Willow"(const 声明,字面量类型)

let 声明的类型会被"拓宽"(widening)为更通用的类型,因为 let 变量可以被重新赋值;const 声明的类型则保留为精确的字面量类型,因为 const 变量的值永远不会改变。

上下文类型(Contextual Typing)

当一个表达式的位置本身就隐含了期望的类型时,TypeScript 会利用这个"上下文"来反向推导类型:

// TypeScript 知道 addEventListener 的 callback 参数类型是 (event: MouseEvent) => void
// 因此 event 参数无需显式标注
document.addEventListener("click", (event) => {
  console.log(event.clientX)  // ✅ event 自动推断为 MouseEvent
})

上下文类型让你在编写回调、事件处理器、数组方法的 lambda 时几乎不需要标注参数类型。

最佳公共类型(Best Common Type)

当推导数组元素的类型时,TypeScript 会计算所有元素的"最佳公共超类型":

const arr1 = [1, 2, 3]           // number[]
const arr2 = [1, "hello"]        // (string | number)[]
const arr3 = [1, null]           // (number | null)[]
const arr4 = []                  // never[](空数组,类型未知)

如果数组元素没有公共超类型(例如完全不相关的类),TypeScript 会退回到联合类型。

💡 最佳实践:大多数时候不需要显式标注类型,相信 TypeScript 的推断能力。只在以下三种场景显式标注:(1) 函数参数——推断引擎无法知道调用者会传什么;(2) 函数的返回值——作为 API 契约,防止内部实现变更意外改变对外签名;(3) 复杂对象字面量——当你希望 TypeScript 能在定义时捕获拼写错误而非等到使用时才报错。


类型层级(Top to Bottom)

类型系统是一个层级结构。理解每个类型在这个层级中的位置,是理解"为什么这个赋值合法"的基础。

unknown                          ← 安全顶端:任何类型可以赋值给 unknown
  │
any                              ← 不安全顶端:绕过了所有类型检查
  │
{}│ null│ undefined              ← 除 null/undefined 外,一切都可以赋给 {}
  │
object, Function, number[], ...  ← 具体对象类型 & 包装类型
  │
string, number, boolean, ...     ← 原始类型
  │
"hello", 42, true, ...           ← 字面量类型(精确到值级别)
  │
never                            ← 空底端:没有任何值属于 never

从顶部(top)到底部(bottom)的梳理:

  • unknown:安全的顶部类型。任何值都可以赋值给 unknown,但在使用 unknown 类型的变量之前,你必须先进行类型收窄(type narrowing)。它是 any 的类型安全替代品。
  • any:不安全的顶部类型。任何值都可以赋给 anyany 也可以赋给任何类型——相当于完全关掉了对这个变量的类型检查。应尽量避免使用。
  • {}nullundefined{} 代表"除 nullundefined 之外的所有值"(在启用 strictNullChecks 后)。它与 object 不同——object 只接受非原始类型,而 {} 接受原始类型。
  • 原始类型stringnumberbooleansymbolbigint——这些是"叶子"类型,没有子类型(除了各自的字面量类型)。
  • 字面量类型:精确到值的类型。“hello” 是 string 的子类型,42number 的子类型。
  • never:底部类型(Bottom Type)。没有任何实际值属于 never。它用于表示"永远不会发生"的情况——抛出异常的函数、无限循环、或被穷尽检查排除后不可能到达的分支。
// 层级关系的实际体现
let u: unknown = "hello"
let s: string = u    // ❌ unknown 不能直接赋给 string
let s2: string = u as string  // ✅ 需要先断言

let a: any = "hello"
let s3: string = a   // ✅ any 可以直接赋给任何类型(不安全!)

let n: never
let s4: string = n   // ✅ never 是所有类型的子类型,可以赋给任何类型

类型兼容性

结构类型系统中的兼容性规则可以总结为一句话:源类型的形状必须包含目标类型需要的所有属性,且对应属性类型兼容。

对象兼容性

源类型的属性可以多于目标类型(多余的属性被忽略),但不能少于目标类型(缺少必需属性)。

interface Animal {
  name: string
}

interface Dog {
  name: string
  breed: string
}

let animal: Animal
let dog: Dog = { name: "Rex", breed: "Husky" }

animal = dog   // ✅ Dog 至少拥有 Animal 的全部属性
// dog = animal  // ❌ Animal 缺少 breed 属性

函数兼容性

函数兼容性的规则更为精细——参数类型是逆变的(contravariant),返回值类型是协变的(covariant)。

  • 协变(Covariance):返回值类型可以是目标返回值的子类型。如果目标期望返回 Animal,你可以返回 Dog(因为 Dog 具有 Animal 的全部属性)。
  • 逆变(Contravariance):参数类型必须是目标参数的父类型。如果目标期望接收 Dog,你的函数必须至少能接收 Dog(即参数类型为 DogAnimal),而不能只接收 Dog 的子类型。
class Animal {
  name: string = ""
}
class Dog extends Animal {
  breed: string = ""
}

// 返回值协变:可以返回更具体的类型
type GetAnimal = () => Animal
type GetDog = () => Dog

let ga: GetAnimal = () => new Animal()
let gd: GetDog = () => new Dog()
let ga2: GetAnimal = gd // ✅ GetDog 的返回值 Dog 是 Animal 的子类型

// 参数逆变:可以接收更泛化的类型
type HandleAnimal = (a: Animal) => void
type HandleDog = (d: Dog) => void

let ha: HandleAnimal = (a: Animal) => {}
let hd: HandleDog = (d: Dog) => {}
let hd2: HandleDog = ha // ✅ HandleAnimal 能接收 Dog(Dog 是 Animal 的子类型)

🔬 深入原理:函数参数为什么是逆变的?考虑实际调用场景——如果某个代码期望调用 (d: Dog) => void,它传入的实参必然是一个 Dog。如果你用 (a: Animal) => void 替换,而 DogAnimal 的子类型,那么 (a: Animal) => void 一定能处理 Dog 参数。反之,如果你用 (d: Poodle) => void(Poodle 是 Dog 的子类型)替换,调用方传入一个 Husky(也是 Dog 的子类型),你的函数无法处理——这就是逆变的方向性。

多余属性检查(Excess Property Checking)

这是一个让很多 TypeScript 学习者困惑的规则。当你将一个对象字面量直接赋值给一个类型标注的变量时,TypeScript 会进行额外的"多余属性检查":

interface User {
  name: string
  age: number
}

// ❌ 对象字面量直接赋值时,检查多余属性
const user: User = { name: "Willow", age: 18, email: "test@test.com" }
// 报错:Object literal may only specify known properties

// ✅ 通过中间变量赋值时,绕过多余属性检查
const data = { name: "Willow", age: 18, email: "test@test.com" }
const user2: User = data // OK

🚨 陷阱:多余属性检查只对对象字面量生效。通过中间变量赋值会绕过检查。这不是 bug,而是 TypeScript 有意设计的"freshness"(新鲜度)机制——对象字面量被推断为"新鲜"类型,其多余属性被视为潜在的拼写错误;而中间变量的类型已经确定,不会再进行多余属性检查。这种设计平衡了"帮你发现错误"和"兼容 JavaScript 现有模式"两个目标。


字面量类型

TypeScript 允许你使用字面量值作为类型——类型系统精确到值的级别。

字符串字面量类型

最常见的用法是与联合类型组合,形成有限集合:

type Direction = "north" | "south" | "east" | "west"
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE"

function navigate(dir: Direction) { /* ... */ }
navigate("north")  // ✅
// navigate("up") // ❌ "up" 不在 Direction 中

数字字面量类型

type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6
type HttpStatus = 200 | 301 | 404 | 500

模板字面量类型(Template Literal Types)

TypeScript 4.1 引入的强大特性——可以基于字符串模板推导新的字符串字面量类型:

type CssValue = `${number}px`
const width: CssValue = "100px"  // ✅
// const width: CssValue = "auto"  // ❌

type EventName = `on${Capitalize<string>}`
// EventName = `on${string}` 其中首字母大写

type Vertical = "top" | "bottom"
type Horizontal = "left" | "right"
type Position = `${Vertical}-${Horizontal}`
// "top-left" | "top-right" | "bottom-left" | "bottom-right"

类型注解 vs 类型推断 — 何时显式标注

TypeScript 的推断能力足够强,但并非所有场景都适合依赖推断。下表总结了决策指南:

场景 推荐 说明
变量赋值(简单值) 推断 类型从值自然得出,显式标注反而是冗余
变量赋值(空数组/空对象) 注解 const arr = [] 推断为 never[],必须手动标注 string[]
函数参数 注解 TypeScript 无法从函数体内推断参数类型
函数返回值 通常注解 作为 API 契约,防止实现变更意外改变对外签名
对象字面量定义 推断 除非需要约束形状以触发多余属性检查
回调函数参数 推断 上下文类型自动推导,显式标注反而是噪音
公共 API 导出 注解 明确的类型签名比推断结果更能传达意图
复杂泛型推导 注解 当泛型推导结果不清晰时,显式指定提升可读性

性能提示:显式标注复杂类型(尤其是包含大型联合类型的返回值)可以帮助 TypeScript 编译器更快完成类型检查——编译器不需要在函数体内部重新推导,只需验证标注类型是否与实现匹配。


编译产物 — TypeScript 是如何工作的

TypeScript 编译器的工作可以概括为:收到 TypeScript 源码,产出 JavaScript,在此过程中进行类型检查。 这两个阶段是解耦的。

类型擦除

绝大多数 TypeScript 语法在编译后完全消失:

// === TypeScript 源码 (source.ts) ===
interface User {
  name: string
  age: number
}

function greet(user: User): string {
  return `Hello, ${user.name}!`
}

const result: string = greet({ name: "Willow", age: 18 })
// === 编译产物 (source.js) ===
function greet(user) {
  return `Hello, ${user.name}!`;
}

const result = greet({ name: "Willow", age: 18 });

所有类型标注(interface: string、类型参数)被完全移除,运行时完全没有类型信息。

会生成代码的 TypeScript 特性

并非所有 TypeScript 语法都在编译时消失。以下特性会生成运行时代码:

特性 编译行为
enum 生成 IIFE(立即调用函数表达式),包含反向映射
const enum 值在编译时内联,不生成代码(运行时零开销)
namespace 生成 IIFE,模拟模块隔离
decorator 生成 __decorate 等辅助函数,注入运行时元数据
class 参数属性 (constructor(private x: number)) 生成 this.x = x 赋值
accessor / auto-accessor 降级为 Object.definePropertyget/set

编译选项的影响

tsconfig.json 中的 targetmodule 选项决定了代码生成的具体策略:

  • target:控制 JavaScript 语法降级程度。target: "ES5" 会将箭头函数转为普通函数、将 async/await 展开为生成器、将模板字符串转为字符串拼接。target: "ESNext" 则保留最新语法,不做降级。
  • module:控制模块系统。module: "commonjs"import/export 转为 require/module.exportsmodule: "esnext" 则保留原生 ES 模块语法。
  • noEmitOnError:设为 true 时,任何类型错误都会阻止生成 JavaScript 文件——这在 CI 环境和生产构建中尤为重要。

🔬 深入原理:TypeScript 编译器的设计原则是"类型空间与值空间分离"。在 TypeScript 源码中,interfacetype、泛型参数、类型标注等属于"类型空间"——它们只存在于编译时,在生成的 JavaScript 中完全消失。而 classenumfunctionconst 声明则属于"值空间"——它们会出现在编译产物中。理解这种分离,能帮助你准确预测任何 TypeScript 语法的编译结果。