阶段二 · 类型系统基础

对象类型, 接口与类型别名

前置回顾

  • 第 02 篇讲了基础类型(number, string, boolean等)和类型注解语法
  • 第 03 篇讲了函数参数和返回值的类型标注
  • 本篇进入对象: TS 中最常用的复合类型, 以及用 interface 和 type 给对象形状命名

一句话总结

interface 和 type 都能描述对象的形状(有哪些字段, 每个字段什么类型). 对象结构优先用 interface, 联合类型和工具类型用 type. TS 是结构化类型系统: 只要形状匹配就兼容, 不需要显式声明"实现了某接口".

对象类型基础

最直接的方式: 内联写出对象的形状:

function printUser(user: { name: string; age: number }) {
console.log(`${user.name}, ${user.age}`);
}

printUser({ name: "Alice", age: 25 });

对比 Go 和 Rust:

// Go: struct 定义
type User struct {
Name string
Age int
}
// Rust: struct 定义
struct User {
name: String,
age: u32,
}

TS 的对象类型更像是"匿名 struct", 可以直接写在参数位置, 不需要提前定义.

可选属性

属性名后加 ?, 表示可以不存在:

function createUser(opts: { name: string; age?: number; email?: string }) {
// age 和 email 的类型是 number | undefined 和 string | undefined
}

createUser({ name: "Alice" }); // 合法
createUser({ name: "Alice", age: 25 }); // 合法
createUser({ name: "Alice", age: 25, email: "a@b.com" }); // 合法

只读属性

readonly 防止属性被修改:

function processRequest(req: { readonly url: string; readonly method: string }) {
// req.url = "/other"; // 编译错误: Cannot assign to 'url' because it is a read-only property
console.log(req.url);
}

对比: Rust struct 字段默认不可变(除非 &mut), Go struct 没有字段级不可变语法.

接口(interface)

当对象类型需要复用时, 用 interface 给它命名:

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

function printUser(user: User) {
console.log(user.name);
}

const alice: User = { id: "1", name: "Alice", age: 25 };

接口继承

用 extends 扩展已有接口, 类似 Go 的接口嵌入:

interface Animal {
name: string;
}

interface Dog extends Animal {
breed: string;
}

const dog: Dog = { name: "Rex", breed: "Labrador" };

支持多继承:

interface Timestamped {
createdAt: Date;
updatedAt: Date;
}

interface SoftDeletable {
deletedAt?: Date;
}

// 同时继承多个接口
interface Post extends Timestamped, SoftDeletable {
title: string;
content: string;
}

接口合并(Declaration Merging)

同名 interface 自动合并为一个:

interface Config {
host: string;
}

interface Config {
port: number;
}

// 等价于 interface Config { host: string; port: number; }
const config: Config = { host: "localhost", port: 3000 };

这是 interface 独有的能力, type 不能做到. 主要用于扩展第三方库的类型定义.

对比 Go 和 Java 的 interface

语言 interface 描述的是 实现方式
Go 行为(方法集合) 隐式实现, 只要有对应方法就满足
Java 行为(方法签名) 显式 implements
TypeScript 形状(属性 + 方法) 隐式满足, 只要结构匹配

Go 的 interface = 职位要求(会写代码, 会沟通), 不管从哪毕业.
TS 的 interface = 物品清单(有名字, 有年龄, 有邮箱), 不管这个对象从哪来.

类型别名(type)

type 给任何类型起一个名字:

// 对象类型(和 interface 等价)
type Point = {
x: number;
y: number;
};

// 联合类型(interface 做不到)
type Status = "pending" | "active" | "deleted";

// 函数类型
type Handler = (event: string, data: unknown) => void;

// 元组
type Pair<T> = [T, T];

type 能做但 interface 不能做的事

// 联合类型
type Result = Success | Error;

// 交叉类型
type AdminUser = User & { role: "admin" };

// 映射类型
type Nullable<T> = { [K in keyof T]: T[K] | null };

// 从值提取类型
const config = { port: 3000, host: "localhost" };
type Config = typeof config; // { port: number; host: string }

interface vs type: 如何选择

能力 interface type
描述对象形状 支持 支持
extends 继承 支持 用 & 交叉实现
声明合并 支持 不支持
联合类型 不支持 支持
映射类型 不支持 支持
元组 不支持 支持
class implements 支持 支持(仅对象形状)

选择策略

  • 对象结构: 优先 interface(可继承, 可合并, IDE 提示更友好)
  • 联合类型, 工具类型, 元组: 必须用 type
  • 两者都行时: 团队统一即可, 不必纠结

社区趋势: React 生态偏好 type, Node.js/后端库偏好 interface. 选一个坚持就好.

索引签名

当对象的 key 不确定, 用索引签名描述"任意键":

interface StringMap {
[key: string]: string;
}

const env: StringMap = {
NODE_ENV: "production",
PORT: "3000",
};

env.DATABASE_URL = "postgres://..."; // 合法, 任意 string key 都行

对比 Go:

// Go 的等价物
var env map[string]string

Record 工具类型

Record<K, V> 是索引签名的语法糖:

// 等价于 { [key: string]: string }
type StringMap = Record<string, string>;

// key 限制为特定联合类型
type Scores = Record<"math" | "english" | "science", number>;
const scores: Scores = { math: 90, english: 85, science: 92 };

嵌套对象与可选链

实际项目中对象经常深层嵌套:

interface Company {
name: string;
address?: {
city: string;
street?: string;
zip?: string;
};
ceo?: {
name: string;
email: string;
};
}

访问深层可选属性时, 用 ?. 可选链(短路求值, 遇到 null/undefined 返回 undefined):

function getCeoEmail(company: Company): string | undefined {
return company.ceo?.email;
}

// 深层访问
const city = company.address?.city; // string | undefined
const zip = company.address?.zip; // string | undefined

空值合并操作符 ??

?? 在左侧为 null 或 undefined 时返回右侧值:

const port = config.port ?? 3000;          // port 为 null/undefined 时用 3000
const host = config.host ?? "localhost";

对比 ||: || 在左侧为任何 falsy 值(0, "", false)时都会走右侧, ?? 只在 null/undefined 时.

const count = 0;
count || 10; // 10(0 是 falsy)
count ?? 10; // 0(0 不是 null/undefined)

对比其他语言:

语言 空值安全访问 默认值
Go if x != nil { x.Field } 无语法糖, 手动判断
Rust x.as_ref()?.field / x.map(...) .unwrap_or(default)
TypeScript x?.field x ?? default

结构化类型系统(鸭子类型)

TS 的类型兼容性基于结构(形状), 不基于名字:

interface Point {
x: number;
y: number;
}

interface Coordinate {
x: number;
y: number;
}

const p: Point = { x: 1, y: 2 };
const c: Coordinate = p; // 合法, 形状相同就兼容, 不需要显式声明关系

只要一个对象拥有接口要求的所有属性, 它就满足该接口. 多余的属性不影响:

interface HasName {
name: string;
}

const user = { name: "Alice", age: 25, email: "a@b.com" };
const named: HasName = user; // 合法, user 有 name 字段就够了

结构化类型 = 看能力不看出身. 只要会游泳就能参加游泳比赛, 不管是人还是鸭子.
名义类型(Java/Rust) = 看证书. 必须显式声明 implements Swimmer 才行.

对比:

语言 类型系统 接口满足方式
Go 结构化(方法) 隐式, 有方法就满足
TypeScript 结构化(属性 + 方法) 隐式, 形状匹配就满足
Rust 名义 显式 impl Trait for Type
Java 名义 显式 implements Interface

多余属性检查

结构化类型的一个例外: 直接传字面量时, TS 会检查多余属性:

interface Point {
x: number;
y: number;
}

// 字面量直接赋值: 多余属性报错
const p: Point = { x: 1, y: 2, z: 3 }; // 编译错误: 'z' does not exist in type 'Point'

// 通过中间变量: 不报错(结构化类型正常行为)
const obj = { x: 1, y: 2, z: 3 };
const p2: Point = obj; // 合法

为什么字面量有特殊检查

直接传字面量时, 多余的属性几乎总是拼写错误(如把 colour 写成了 color). TS 的多余属性检查是一个实用的防错机制, 但它只在字面量赋值时触发.

实用工具类型

TS 内置了一组工具类型, 基于已有类型生成新类型:

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

// Partial: 所有属性变为可选
type UpdateUserDto = Partial<User>;
// { id?: string; name?: string; email?: string; age?: number }

// Required: 所有属性变为必选(Partial 的逆操作)
type CompleteUser = Required<UpdateUserDto>;

// Pick: 只保留指定属性
type UserPreview = Pick<User, "id" | "name">;
// { id: string; name: string }

// Omit: 排除指定属性
type UserWithoutId = Omit<User, "id">;
// { name: string; email: string; age: number }

// Readonly: 所有属性变为只读
type FrozenUser = Readonly<User>;

这些工具类型的实现原理(映射类型 + 条件类型)在阶段三详细讲. 现在只需要会用.

Partial 的典型使用场景

更新操作: 只传需要修改的字段, 其余保持不变.

function updateUser(id: string, changes: Partial<User>) {
// changes 里只有需要更新的字段
}
updateUser("1", { name: "Bob" }); // 只更新 name

Pick 和 Omit 哪个更好

字段少时用 Pick(白名单), 字段多时用 Omit(黑名单). 选让代码更短的那个.
但 Pick 在源接口添加字段时不会自动带入, 更安全; Omit 会自动带入新字段, 可能无意暴露数据.

快速回顾

  • 对象类型: 内联写 { key: type }, 支持可选(?)和只读(readonly)属性
  • interface: 给对象形状命名, 支持继承(extends)和声明合并
  • type: 给任何类型命名, 支持联合, 交叉, 映射, 元组
  • 选择策略: 对象结构用 interface, 联合/工具类型用 type
  • 索引签名: [key: string]: T 描述动态键, 或用 Record<K, V>
  • 可选链: ?. 安全访问深层属性, ?? 提供空值默认值
  • 结构化类型: 形状匹配就兼容, 不需要显式声明实现关系
  • 工具类型: Partial, Pick, Omit, Readonly 基于已有类型派生新类型

动手练习

  1. 定义接口: 为一个博客系统定义 Post, Author, Comment 三个接口, 建立它们之间的关系(Post 有 author 和 comments).
  2. interface 继承: 定义 BaseEntity(id, createdAt, updatedAt), 让 Post 和 Author 都继承它.
  3. type vs interface: 用 type 定义一个 ApiResponse<T> = { data: T; error: string | null } 泛型类型, 思考为什么这里用 type 更合适.
  4. 可选链练习: 给定一个深层嵌套的配置对象, 用 ?. 和 ?? 安全地读取各级属性并提供默认值.
  5. 鸭子类型验证: 定义两个不同名但形状相同的 interface, 验证它们之间可以互相赋值.
  6. 工具类型组合: 用 Omit + Partial 组合出一个"创建 Post"的 DTO 类型: id 不需要传, 其余字段可选.