Skip to content

深入理解 TypeScript 类型系统

一句话概述:结构化类型、类型兼容性、协变逆变、类型推断——理解 TS 类型系统的底层逻辑

什么是 TypeScript 类型系统?

定义:TypeScript 的类型系统是一套在编译期对代码进行静态分析的规则集合,用于在不运行代码的情况下发现类型错误。

核心特征

  • 结构化类型(Structural Typing):按结构判断兼容性,而非名称
  • 渐进式类型(Gradual Typing):可以逐步为 JS 代码添加类型
  • 类型擦除(Type Erasure):编译后所有类型信息被移除
  • 图灵完备:类型系统本身可以进行复杂计算(类型体操的基础)

作用

  1. 编译期捕获错误,减少运行时 bug
  2. 提供 IDE 智能提示和重构支持
  3. 作为代码文档,提升可维护性

一、结构化类型 vs 名义化类型

核心区别

TypeScript 采用结构化类型系统(Structural Type System),判断类型兼容性时只看结构,不看名字。这与 Java/C#/Rust 的名义化类型系统(Nominal Type System)截然不同。

typescript
// ========== 结构化类型(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 对对象字面量直接赋值时会额外检查多余属性:

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 的子类型:

typescript
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 的类型形成一个层级结构:

text
        any(顶层,逃生舱)

       unknown(安全的顶层类型)

    ┌────┼────┐
    │    │    │
  string number boolean  object  ...(具体类型)
    │    │    │
  "hello" 42  true       ...(字面量类型)
    │    │    │
    └────┼────┘

       never(底层类型)
  • any:顶层类型 + 底层类型(双向兼容,破坏类型安全)
  • unknown:真正的顶层类型(任何类型可赋值给 unknown,但 unknown 不能赋值给其他类型)
  • never:底层类型(never 可以赋值给任何类型,但没有类型可以赋值给 never)
typescript
// unknown 是安全的顶层类型
let u: unknown = 42;        // ✅ 任何值都能赋给 unknown
// let n: number = u;        // ❌ 必须先收窄

// never 是底层类型
function throwError(): never {
  throw new Error('!');
}
let n: number = throwError(); // ✅ never 可以赋给任何类型

三、协变与逆变

什么是型变(Variance)?

型变描述的是:当类型之间存在子类型关系时,由它们构成的复合类型之间的子类型关系如何变化。

四种型变

typescript
// 假设 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):两个方向都不兼容
//    出现位置:同时可读可写的泛型位置

协变(返回值位置)

typescript
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

逆变(参数位置)

typescript
// 函数参数是逆变的(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
// TypeScript 对方法声明采用双变检查(历史兼容原因)
interface Handler {
  handle(event: MouseEvent): void;  // 方法语法:双变
}

interface Handler2 {
  handle: (event: MouseEvent) => void;  // 属性语法:逆变(strict 下)
}

// 这就是为什么 strictFunctionTypes 只影响函数表达式,不影响方法声明

逆变的实际应用:UnionToIntersection

typescript
// 利用函数参数的逆变特性,将联合类型转为交叉类型
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 在很多场景下可以自动推断类型,不需要显式标注:

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)

typescript
// 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)

typescript
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)

结构化类型的一个问题是:结构相同但语义不同的类型可以互换,这在某些场景下不安全。

问题场景

typescript
// 用户 ID 和订单 ID 都是 string,但不应该混用
type UserId = string;
type OrderId = string;

function getUser(id: UserId) { /* ... */ }

const orderId: OrderId = 'order_123';
getUser(orderId); // ✅ 不报错!但逻辑上是错误的

解决方案:Branded Types

typescript
// 方案一:交叉类型 + 品牌字段
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');

实际应用场景

typescript
// 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. anyunknown 有什么区别?为什么推荐 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 未验证数据等。运行时零开销,因为品牌属性只存在于类型层面。