类型系统基础与心智模型

类型系统基础与心智模型

写在前面

会用 TypeScript 的人很多,但理解 TypeScript 类型系统设计哲学的人不多。

这一篇不讲 interface 怎么写、type 怎么用——这些一查文档就有。本篇讲根本问题:TypeScript 的类型到底是什么?为什么这样设计?什么时候它保护你,什么时候它坑你?

理解了这层心智模型,后面的联合类型、泛型、条件类型都是自然延伸。

一、类型是什么

1.1 一个常见的误解

很多新手觉得:“TypeScript 的类型就是给变量加约束,加错了会报错”。

这个理解是片面的。

TypeScript 的类型实际上是对值的描述——它告诉你"这个值长什么样",而不是"这个值必须做什么"。

function greet(name: string) {
    console.log(`Hello, ${name}`);
}

greet("World");     // OK
greet(42);          // 报错:Argument of type 'number' is not assignable to parameter of type 'string'

看起来"类型就是约束"对吧?但往下看:

type User = {
    name: string;
    age: number;
};

function celebrate(user: User) {
    console.log(`Happy ${user.age}th birthday, ${user.name}!`);
}

const alice = { name: "Alice", age: 30, hobby: "reading" };
celebrate(alice);   // OK,虽然 hobby 不在 User 里

TypeScript 不要求传入的对象"长得完全像 User",只要"结构兼容"。这跟 Java、C# 完全不一样。

1.2 类型的两个作用

类型在 TypeScript 里扮演两个角色:

  1. 约束(Constraint):在编译期发现错误
  2. 文档(Documentation):让代码更易读、易理解、易重构
// 没有类型 - 你不知道这个函数期望什么
function processUser(user) {
    return user.name.toUpperCase();
}

// 有类型 - 一目了然
interface User {
    id: string;
    name: string;
    age: number;
}

function processUser(user: User): string {
    return user.name.toUpperCase();
}

类型是给读代码的人看的,也是给 IDE 看的。当你接手一个老项目,TypeScript 类型就是最准确的文档——比 README 准确,比注释不会过期。

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

2.1 两种类型系统

维度 结构化类型(Structural) 名义类型(Nominal)
判断兼容 看结构(字段) 看名字(类型)
语言 TypeScript、Go、Python(部分) Java、C#、C++
灵活度 高 低
严格度 低 高

2.2 例子对比

// TypeScript: 结构化
type User = { name: string; age: number };
type Employee = { name: string; age: number };

const e: Employee = { name: "Bob", age: 25 };
const u: User = e;     // OK - 结构兼容

// Java 风格的"严格"方式
type User = { name: string; age: number; __brand: "User" };
type Employee = { name: string; age: number; __brand: "Employee" };

const e: Employee = { name: "Bob", age: 25, __brand: "Employee" };
const u: User = e;     // 报错 - 不同的 __brand

TypeScript 是结构化类型——只要字段匹配就能赋值。

2.3 结构化类型的优劣

优势:

劣势:

2.4 实战:结构化类型带来的坑

// 第三方库的"凭证"类型
type Credentials = {
    username: string;
    password: string;
};

// 我自己定义的"用户"类型
type User = {
    username: string;  // 巧合字段名一样
    password: string;
};

// 看起来没问题?
function login(c: Credentials) {
    return api.login(c);
}

const user: User = getUser();
login(user);   // 编译通过! 但语义上不对

结构化类型有时候太宽容——User 和 Credentials 字段一样就能互换,但语义完全不同。

解决:用 brand types(后面讲)。

三、协变与逆变

3.1 什么是协变和逆变

当类型 A 是 B 的子类型(A extends B)时,考虑容器类型 Container<A> 和 Container<B> 的关系。

术语 关系 适用场景
协变(Covariant) Container<A> 是 Container<B> 的子类型 输出位置(返回值)
逆变(Contravariant) Container<B> 是 Container<A> 的子类型 输入位置(参数)
不变(Invariant) 没有子类型关系 读写都有的位置
双变(Bivariant) 同时是协变和逆变 特殊场景

3.2 函数的协变/逆变

// 返回值:协变
// 如果 A extends B,那么 () => A 是 () => B 的子类型
// (因为调用者期待 B,实际返回 A 也行)

type Animal = { name: string };
type Dog = Animal & { bark(): void };

// 返回 Dog 的函数,可以赋值给返回 Animal 的函数
const dogFactory: () => Dog = () => ({ name: "旺财", bark: () => {} });
const animalFactory: () => Animal = dogFactory;  // OK
// 参数:逆变
// 如果 A extends B,那么 (b: B) => void 是 (a: A) => void 的子类型
// (因为参数要求更宽,实际能处理更窄的类型)

type Animal = { name: string };
type Dog = Animal & { bark(): void };

// 接受 Animal 的函数,可以赋值给接受 Dog 的函数
const handleAnimal: (a: Animal) => void = (a) => console.log(a.name);
const handleDog: (d: Dog) => void = handleAnimal;  // OK

// 反过来不行
const handleDogOnly: (d: Dog) => void = (d) => d.bark();
const handleAnimalWrong: (a: Animal) => void = handleDogOnly;  // 报错!

为什么参数是逆变? 因为函数可以"承诺"更少的输入类型(更宽),但不能"要求"更多的输入类型(更窄)。

// 实际:handleAnimal 可以处理任何 Animal,包括 Dog
// 但 handleDogOnly 只能处理 Dog
// 如果把 handleDogOnly 传给期待 handleAnimal 的地方
// 调用者传入 Cat(Animal 子类),handleDogOnly 会尝试调用 bark() 而出错

3.3 TypeScript 的实际行为

TypeScript 4.x 之前参数是双变的(更宽松),4.x 之后是严格逆变(strictFunctionTypes)。

// tsconfig.json
{
    "compilerOptions": {
        "strictFunctionTypes": true   // 默认 true,推荐
    }
}

3.4 实战:协变逆变的真实例子

interface EventEmitter<T> {
    on(event: string, listener: (data: T) => void): void;
    emit(event: string, data: T): void;
}

// stringEmitter: EventEmitter<string>
const stringEmitter: EventEmitter<string> = ...;

// 协变 - 安全
const anyEmitter: EventEmitter<any> = stringEmitter;

// 逆变 - 注意参数
const numberEmitter: EventEmitter<number> = stringEmitter;
//              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// 报错: Type 'EventEmitter<string>' is not assignable to 'EventEmitter<number>'
// 因为 listener 是 (data: string) => void,不能安全地传给 (data: number) => void

四、双变(bivariance)

4.1 什么是双变

TypeScript 早期(4.0 前)对方法(method shorthand)的参数检查是双变的:

interface Animal { name: string }
interface Dog extends Animal { bark(): void }

// 方法写法
interface Comparer {
    compare(a: Animal): number;     // 双变 - 早期
}

// 函数属性写法
interface Comparer2 {
    compare: (a: Animal) => number; // 严格逆变 - 4.0+
}

const dogComparer: Comparer = {
    compare: (d: Dog) => d.bark().length  // 双变允许
};

const dogComparer2: Comparer2 = {
    compare: (d: Dog) => d.bark().length  // 报错!严格逆变
};

为什么 TypeScript 早期用双变? 简单(减少类型注解的繁琐),代价是不够严格。

五、readonly 和可变性的协变

5.1 readonly 是协变

type Mutable = { x: number };
type Readonly = { readonly x: number };

const m: Mutable = { x: 1 };
const r: Readonly = m;   // OK - Mutable 是 Readonly 的子类型

// 反过来不行
const m2: Mutable = r;   // 报错 - Readonly 不能赋值给 Mutable

readonly 让类型更"宽"——只读比可写更宽松。

5.2 数组的协变

TypeScript 的数组是协变的——这跟"严格"类型系统不一样。

// 协变
const animals: Animal[] = [];
const dogs: Dog[] = [];
animals = dogs;   // OK

为什么这样设计? 跟 JavaScript 的行为一致(JS 数组就是对象,duck typing)。

但这有运行时不安全的风险:

animals.push(new Cat());  // TypeScript 报错
//                  ^^^^
// Type 'Cat' is missing the following properties from type 'Dog': bark

TypeScript 阻止了错误——因为 animals: Animal[] 的 push 参数期望 Animal,传入 Cat 不行(协变下,Dog[] 是 Animal[] 的子类型,但写操作会被检查)。

六、TypeScript vs Java 类型系统对比

维度 TypeScript Java
类型系统 结构化 名义
泛型协变 默认不变 协变(数组) / 不变(集合)
函数参数 严格逆变(strictFunctionTypes) 逆变
类型擦除 编译时存在,运行时不存 编译时存在,运行时不存(泛型)
运行时类型 有(JS 本身) 没有
编译产物 JS JVM 字节码

TypeScript 跟 Java 的"看着像"完全是两码事——很多 Java 转 TS 的人会踩坑,比如"两个不同的类即使字段一样也不能互相赋值"(Java 行为),但 TypeScript 就可以。

七、TypeScript 的"特殊"类型

7.1 any

let x: any = 1;
x.foo.bar.baz;  // 编译通过,运行时报错

any 关闭了类型检查——等于回到 JavaScript。

用 any 等于放弃了 TypeScript 的好处。尽量用 unknown。

7.2 unknown

let x: unknown = 1;
x.foo;  // 报错:Object is of type 'unknown'

// 必须先 narrow(收窄)
if (typeof x === "string") {
    x.toUpperCase();  // OK,这里 x 是 string
}

unknown 是类型安全的 any——用之前必须 narrow。

7.3 never

function throwError(): never {
    throw new Error();
}

function loopForever(): never {
    while (true) {}
}

never 是"没有任何值"的类型——用于:

type Shape = "circle" | "square";

function area(s: Shape) {
    switch (s) {
        case "circle": return ...;
        case "square": return ...;
        default:
            // 这里的 s 是 never,如果漏了 case,这里会报错
            const _exhaustive: never = s;
    }
}

7.4 void

function log(): void {
    console.log("hi");
}

void 表示"没有有意义的返回值"。undefined 是 void 的子类型。

八、实战:用类型改善代码

8.1 改前:JS 风格

function fetchUser(id, callback) {
    fetch(`/api/users/${id}`)
        .then(res => res.json())
        .then(data => callback(null, data))
        .catch(err => callback(err));
}

fetchUser("123", (err, user) => {
    if (err) {
        // 处理错误
    }
    console.log(user.name);  // user 是什么类型?
});

8.2 改后:TS 风格

interface User {
    id: string;
    name: string;
    email: string;
    age: number;
}

type Result<T> =
    | { success: true; data: T }
    | { success: false; error: Error };

async function fetchUser(id: string): Promise<Result<User>> {
    try {
        const res = await fetch(`/api/users/${id}`);
        const data: User = await res.json();
        return { success: true, data };
    } catch (error) {
        return { success: false, error: error as Error };
    }
}

const result = await fetchUser("123");
if (result.success) {
    // 这里 result.data 是 User 类型
    console.log(result.data.name);
} else {
    // 这里 result.error 是 Error 类型
    console.error(result.error);
}

类型让代码更安全,也更易读。

九、常见心智误区

9.1 误区 1:“类型越多越好”

错。类型是工具,不是越多越好。

// 过度类型化
type GetUserRequest = {
    type: "GET_USER";
    payload: {
        id: string;
        includeProfile: boolean;
        includeOrders: boolean;
    };
};
type GetOrderRequest = { ... };
type Request = GetUserRequest | GetOrderRequest | ...;  // 越来越长

如果类型太复杂,重构比写起来更痛苦。

9.2 误区 2:“类型 = 运行时检查”

错。TypeScript 类型在编译后完全消失——JavaScript 运行时没有任何类型信息。

function greet(name: string) { }

// 编译后
function greet(name) { }  // 没有任何类型

如果你需要运行时验证,需要额外的库(zod、yup、io-ts、valibot)。

9.3 误区 3:“any 能解决一切”

any 是"我放弃了类型检查"的意思。用 unknown 替代。

9.4 误区 4:“TS 严格模式越严越好”

strict: true 是好的,但**noImplicitAny、strictNullChecks 等要按团队能力逐步打开**。

新项目:strict: true。 老项目:逐步打开,不要一步到位。

十、本篇小结

概念 关键点
结构化类型 看结构不看名字,TypeScript 的核心
协变 子类型 → 容器子类型(输出)
逆变 子类型 → 父类型 → 容器父类型(输入)
双变 TS 早期参数行为,4.0+ 严格逆变
any 关闭类型检查,慎用
unknown 类型安全的 any
心智模型 理解
类型是约束 ✅ 但也是文档
类型越多越好 ❌ 越简单越好
类型 = 运行时检查 ❌ 类型运行时不存在

下一篇讲 联合类型与类型守卫——把"业务里"用得最多的 TS 模式讲透。

参考资料