深入理解 TypeScript 类型系统
一句话概述:结构化类型、类型兼容性、协变逆变、类型推断——理解 TS 类型系统的底层逻辑
什么是 TypeScript 类型系统?
定义:TypeScript 的类型系统是一套在编译期对代码进行静态分析的规则集合,用于在不运行代码的情况下发现类型错误。
核心特征:
- 结构化类型(Structural Typing):按结构判断兼容性,而非名称
- 渐进式类型(Gradual Typing):可以逐步为 JS 代码添加类型
- 类型擦除(Type Erasure):编译后所有类型信息被移除
- 图灵完备:类型系统本身可以进行复杂计算(类型体操的基础)
作用:
- 编译期捕获错误,减少运行时 bug
- 提供 IDE 智能提示和重构支持
- 作为代码文档,提升可维护性
一、结构化类型 vs 名义化类型
核心区别
TypeScript 采用结构化类型系统(Structural Type System),判断类型兼容性时只看结构,不看名字。这与 Java/C#/Rust 的名义化类型系统(Nominal Type System)截然不同。
// ========== 结构化类型(TypeScript)==========
interface Dog {
name: string;
breed: string;
}
interface Pet {
name: string;
breed: string;
}
const dog: Dog = { name: 'Buddy', breed: 'Labrador' };
const pet: Pet = dog; // ✅ 结构相同,兼容
// ========== 如果是名义化类型(Java 风格伪代码)==========
// Dog 和 Pet 即使结构完全一样,也不兼容
// 必须显式声明 class Dog implements Pet为什么选择结构化类型?
- 与 JavaScript 的鸭子类型哲学一致:JS 本身就是按"能不能用"判断的
- 更灵活:不需要预先声明实现关系,方便渐进式迁移
- 更适合前端生态:第三方库类型兼容性更好
多余属性检查(Excess Property Check)
结构化类型允许子类型有多余属性,但 TypeScript 对对象字面量直接赋值时会额外检查多余属性:
interface Point {
x: number;
y: number;
}
// ❌ 对象字面量直接赋值:报错
const p1: Point = { x: 1, y: 2, z: 3 };
// Error: Object literal may only specify known properties
// ✅ 通过中间变量:不报错(结构化兼容)
const temp = { x: 1, y: 2, z: 3 };
const p2: Point = temp;
// ✅ 类型断言:跳过检查
const p3: Point = { x: 1, y: 2, z: 3 } as Point;为什么有这个特殊规则? 对象字面量直接赋值时多余属性大概率是拼写错误,这是 TypeScript 在灵活性和安全性之间的折中。
二、类型兼容性
子类型(Subtype)
在结构化类型系统中,如果 A 的所有属性 B 都有(B 可以有更多属性),则 B 是 A 的子类型:
interface Animal {
name: string;
}
interface Dog extends Animal {
breed: string;
}
// Dog 是 Animal 的子类型
let animal: Animal;
let dog: Dog = { name: 'Buddy', breed: 'Lab' };
animal = dog; // ✅ 子类型可以赋值给父类型(向上兼容)
// dog = animal; // ❌ 父类型不能赋值给子类型(缺少 breed)类型层级
TypeScript 的类型形成一个层级结构:
any(顶层,逃生舱)
│
unknown(安全的顶层类型)
│
┌────┼────┐
│ │ │
string number boolean object ...(具体类型)
│ │ │
"hello" 42 true ...(字面量类型)
│ │ │
└────┼────┘
│
never(底层类型)any:顶层类型 + 底层类型(双向兼容,破坏类型安全)unknown:真正的顶层类型(任何类型可赋值给 unknown,但 unknown 不能赋值给其他类型)never:底层类型(never 可以赋值给任何类型,但没有类型可以赋值给 never)
// unknown 是安全的顶层类型
let u: unknown = 42; // ✅ 任何值都能赋给 unknown
// let n: number = u; // ❌ 必须先收窄
// never 是底层类型
function throwError(): never {
throw new Error('!');
}
let n: number = throwError(); // ✅ never 可以赋给任何类型三、协变与逆变
什么是型变(Variance)?
型变描述的是:当类型之间存在子类型关系时,由它们构成的复合类型之间的子类型关系如何变化。
四种型变
// 假设 Dog extends Animal
// 1. 协变(Covariance):方向保持
// Dog <: Animal → Array<Dog> <: Array<Animal>
// 出现位置:函数返回值、只读属性、Promise<T>
// 2. 逆变(Contravariance):方向反转
// Dog <: Animal → Consumer<Animal> <: Consumer<Dog>
// 出现位置:函数参数(strictFunctionTypes 开启时)
// 3. 双变(Bivariance):两个方向都兼容
// 出现位置:方法参数(TS 的历史兼容设计)
// 4. 不变(Invariance):两个方向都不兼容
// 出现位置:同时可读可写的泛型位置协变(返回值位置)
class Animal { name = '' }
class Dog extends Animal { breed = '' }
class Cat extends Animal { indoor = true }
// 函数返回值是协变的
type Producer<T> = () => T;
let produceDog: Producer<Dog> = () => new Dog();
let produceAnimal: Producer<Animal> = produceDog; // ✅ 协变
// 返回 Dog 的函数可以当作返回 Animal 的函数使用
// 因为 Dog 一定是 Animal逆变(参数位置)
// 函数参数是逆变的(strictFunctionTypes: true)
type Consumer<T> = (arg: T) => void;
let feedAnimal: Consumer<Animal> = (a: Animal) => console.log(a.name);
let feedDog: Consumer<Dog> = feedAnimal; // ✅ 逆变
// 能处理 Animal 的函数一定能处理 Dog
// 因为 Dog 一定是 Animal
// 反过来不行
let trainDog: Consumer<Dog> = (d: Dog) => console.log(d.breed);
// let trainAnimal: Consumer<Animal> = trainDog; // ❌
// 只能处理 Dog 的函数不一定能处理 Cat双变(方法声明的特殊处理)
// TypeScript 对方法声明采用双变检查(历史兼容原因)
interface Handler {
handle(event: MouseEvent): void; // 方法语法:双变
}
interface Handler2 {
handle: (event: MouseEvent) => void; // 属性语法:逆变(strict 下)
}
// 这就是为什么 strictFunctionTypes 只影响函数表达式,不影响方法声明逆变的实际应用:UnionToIntersection
// 利用函数参数的逆变特性,将联合类型转为交叉类型
type UnionToIntersection<U> =
(U extends any ? (arg: U) => void : never) extends (arg: infer I) => void
? I
: never;
// 原理:
// 1. 分发:(arg: A) => void | (arg: B) => void
// 2. 推断 infer I:逆变位置的多个候选 → 取交叉
// 3. 结果:A & B
type R = UnionToIntersection<{ a: 1 } | { b: 2 }>;
// { a: 1 } & { b: 2 }四、类型推断
推断的基本原则
TypeScript 在很多场景下可以自动推断类型,不需要显式标注:
// 1. 变量初始化推断
let x = 42; // number
const y = 'hello'; // "hello"(const 推断为字面量类型)
// 2. 函数返回值推断
function add(a: number, b: number) {
return a + b; // 返回类型推断为 number
}
// 3. 上下文类型推断(contextual typing)
// 从"使用位置"反向推断类型
const handler: (e: MouseEvent) => void = (e) => {
console.log(e.clientX); // e 被推断为 MouseEvent
};
// 数组方法的回调
[1, 2, 3].map(n => n.toFixed(2)); // n 推断为 number
// 4. 泛型参数推断
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
first([1, 2, 3]); // T 推断为 number类型拓宽(Type Widening)
// let 声明会拓宽为基础类型
let a = 'hello'; // string(拓宽)
const b = 'hello'; // "hello"(不拓宽)
// 对象属性会拓宽
const obj = { x: 1, y: 'hello' };
// { x: number, y: string }(属性值被拓宽)
// as const 阻止拓宽
const obj2 = { x: 1, y: 'hello' } as const;
// { readonly x: 1, readonly y: "hello" }(保留字面量类型)类型收窄(Type Narrowing)
function process(value: string | number | null) {
// 1. typeof 收窄
if (typeof value === 'string') {
value; // string
return;
}
// 2. 真值检查
if (value) {
value; // number(排除了 null 和 string)
}
// 3. 等值检查
if (value === null) {
value; // null
return;
}
value; // number
}
// 4. 控制流分析(Control Flow Analysis)
function getLength(x: string | string[]) {
if (Array.isArray(x)) {
return x.length; // string[]
}
return x.length; // string
}
// 5. 可辨识联合(最强大的收窄模式)
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rect'; width: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2; // 收窄为 circle
case 'rect':
return shape.width * shape.height; // 收窄为 rect
}
}五、模拟名义类型(Branded Types)
结构化类型的一个问题是:结构相同但语义不同的类型可以互换,这在某些场景下不安全。
问题场景
// 用户 ID 和订单 ID 都是 string,但不应该混用
type UserId = string;
type OrderId = string;
function getUser(id: UserId) { /* ... */ }
const orderId: OrderId = 'order_123';
getUser(orderId); // ✅ 不报错!但逻辑上是错误的解决方案:Branded Types
// 方案一:交叉类型 + 品牌字段
declare const __brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [__brand]: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
// 创建函数
function createUserId(id: string): UserId {
return id as UserId;
}
function createOrderId(id: string): OrderId {
return id as OrderId;
}
function getUser(id: UserId) { /* ... */ }
const userId = createUserId('user_123');
const orderId = createOrderId('order_456');
getUser(userId); // ✅
// getUser(orderId); // ❌ 类型不兼容
// 方案二:配合 Zod 做运行时校验
import { z } from 'zod';
const UserIdSchema = z.string().uuid().brand('UserId');
type UserId2 = z.infer<typeof UserIdSchema>;
const userId2 = UserIdSchema.parse('550e8400-e29b-41d4-a716-446655440000');实际应用场景
// 1. 数值单位区分
type Pixels = Brand<number, 'Pixels'>;
type Rem = Brand<number, 'Rem'>;
type Percentage = Brand<number, 'Percentage'>;
function setWidth(value: Pixels) { /* ... */ }
setWidth(100 as Pixels); // ✅
// setWidth(100 as Percentage); // ❌
// 2. 已验证数据标记
type Email = Brand<string, 'Email'>;
type ValidatedInput = Brand<string, 'ValidatedInput'>;
function validateEmail(input: string): Email {
if (!input.includes('@')) throw new Error('Invalid email');
return input as Email;
}
function sendEmail(to: Email) { /* ... */ }
// sendEmail('not-validated'); // ❌ string 不能赋给 Email
sendEmail(validateEmail('user@example.com')); // ✅面试高频题
1. TypeScript 为什么选择结构化类型系统?
答案:因为 JavaScript 本身是鸭子类型语言("走起来像鸭子、叫起来像鸭子,那它就是鸭子"),TypeScript 作为 JS 的超集,结构化类型系统与 JS 的运行时行为最匹配。此外,前端生态中大量使用对象字面量和接口适配,结构化类型让渐进式迁移更容易。
2. any 和 unknown 有什么区别?为什么推荐 unknown?
答案:any 关闭类型检查(既是顶层类型又是底层类型),可以赋值给任何类型也能接受任何类型,完全绕过类型系统。unknown 是真正的顶层类型,可以接受任何值,但使用前必须通过类型收窄确认具体类型。推荐 unknown 是因为它强制你在使用前做类型检查,保持类型安全。
3. 什么是多余属性检查?为什么中间变量赋值就不报错?
答案:多余属性检查是 TypeScript 对对象字面量直接赋值时的额外检查,报告目标类型中不存在的属性。这是为了捕获拼写错误等常见 bug。中间变量赋值不报错是因为此时走的是标准的结构化兼容性检查——只要包含所需属性就兼容,多余属性被忽略。这是 TS 在实用性和安全性之间的平衡。
4. 协变和逆变在实际开发中有什么影响?
答案:最常见的影响是函数类型的兼容性。比如 React 的事件处理器:(e: MouseEvent) => void 可以赋值给 (e: Event) => void 吗?在 strictFunctionTypes 下,参数是逆变的,所以反过来才行——能处理 Event 的函数可以处理 MouseEvent,但只能处理 MouseEvent 的函数不一定能处理所有 Event。理解协变逆变也是实现 UnionToIntersection 等高级类型工具的基础。
5. 如何在 TypeScript 中模拟名义类型?
答案:使用 Branded Types(品牌类型),通过交叉一个虚拟的品牌属性来区分结构相同但语义不同的类型。常用于区分不同类型的 ID(UserId vs OrderId)、数值单位(Pixels vs Rem)、已验证 vs 未验证数据等。运行时零开销,因为品牌属性只存在于类型层面。