类型设计哲学:合法状态 vs 非法状态,12条原则
第12章:类型设计哲学:合法状态 vs 非法状态,12条原则
理解类型设计哲学是掌握 TypeScript 类型系统的关键一步。
本章核心问题:如何在实际项目中正确使用合法状态 vs 非法状态,12条原则?关键的设计决策和陷阱是什么?
读完本章你将理解:
- 核心命题
- 原则 1:让非法状态无法表达(核心原则)
- 原则 2:用接口联合替代可选字段
Level 1 · 你需要知道的(1-3年经验)
核心命题
类型系统的价值不在于给变量贴标签,而在于让编译器替你排除一整类错误的可能性。好的类型设计让非法状态在代码层面根本无法表达,坏的类型设计让合法代码和错误代码看起来一模一样。
以下 12 条原则来自生产代码的真实教训,每条都有反例和正解。
原则 1:让非法状态无法表达(核心原则)
这是所有原则的根基。如果你的类型允许表达一个永远不应该出现的状态,那么你的代码就必须用运行时检查来补偿这个设计缺陷。
// 反例:两个字段互相依赖,类型无法保证一致性
interface FetchState {
isLoading: boolean;
data: string | null;
error: Error | null;
}
// 这个状态合法但没有意义:isLoading=true 同时 data 和 error 都存在
const broken: FetchState = {
isLoading: true,
data: "some data",
error: new Error("also an error"),
};
// 正解:用可辨识联合,每种状态自成一体
type FetchState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string }
| { status: "error"; error: Error };
// 现在不可能同时有 data 和 error
function render(state: FetchState) {
switch (state.status) {
case "idle": return "请点击加载";
case "loading": return "加载中...";
case "success": return state.data; // 编译器知道 data 存在
case "error": return state.error.message; // 编译器知道 error 存在
}
}
原则 2:用接口联合替代可选字段
接口上的可选字段(?)暗示字段"可能存在",但无法说明"什么情况下存在"。接口联合能精确描述这种关联。
// 反例:可选字段无法约束字段间关系
interface Payment {
method: "card" | "paypal" | "bank";
cardNumber?: string;
cardExpiry?: string;
paypalEmail?: string;
bankAccount?: string;
bankRoutingNumber?: string;
}
// 类型允许这种荒谬的值:
const p: Payment = {
method: "card",
paypalEmail: "[email protected]", // 选了 card 却填了 paypal 信息
};
// 正解:每种支付方式有自己的接口
interface CardPayment {
method: "card";
cardNumber: string;
cardExpiry: string;
}
interface PaypalPayment {
method: "paypal";
paypalEmail: string;
}
interface BankPayment {
method: "bank";
bankAccount: string;
bankRoutingNumber: string;
}
type Payment = CardPayment | PaypalPayment | BankPayment;
function processPayment(payment: Payment) {
if (payment.method === "card") {
// 这里 cardNumber 和 cardExpiry 必定存在,无需检查
charge(payment.cardNumber, payment.cardExpiry);
}
}
原则 3:把 null 推到边界层
null 应该在数据进入系统的那一刻被处理掉,不应该穿越多个函数层。内层函数接收到的值应该是干净的非空值。
// 反例:null 在业务逻辑深处传播
function getDisplayName(user: User | null): string | null {
if (!user) return null;
const profile = user.profile ?? null;
if (!profile) return null;
return profile.displayName ?? null; // 调用者还要处理 null
}
// 调用链:每一层都要判 null
const name = getDisplayName(getUser(id));
if (name !== null) {
render(name);
}
// 正解:边界层处理 null,内层函数接收确定值
function getUser(id: string): User | null {
return db.find(id) ?? null; // null 只在这里
}
// 内层函数:假设输入合法,不处理 null
function getDisplayName(user: User): string {
return user.profile?.displayName ?? user.email;
}
// 调用点:一次处理 null,之后全是干净值
const user = getUser(id);
if (user) {
const name = getDisplayName(user); // string,不是 string | null
render(name);
}
原则 4:不同概念用不同类型
UserId 和 PostId 运行时都是字符串,但它们是不同的概念。用同一个 string 类型表示两者,编译器就无法阻止你把帖子 ID 传给期望用户 ID 的函数。
// 反例:所有 ID 都是 string,编译器无法区分
function getPost(userId: string, postId: string): Post {
return db.posts.findOne({ author: userId, id: postId });
}
const userId = "user_123";
const postId = "post_456";
getPost(postId, userId); // 参数顺序搞反了,编译器不报错
// 正解:为每种 ID 创建独立类型(详见第13章 Branded Types)
type UserId = string & { readonly __brand: "UserId" };
type PostId = string & { readonly __brand: "PostId" };
function makeUserId(s: string): UserId { return s as UserId; }
function makePostId(s: string): PostId { return s as PostId; }
function getPost(userId: UserId, postId: PostId): Post {
return db.posts.findOne({ author: userId, id: postId });
}
const userId = makeUserId("user_123");
const postId = makePostId("post_456");
getPost(postId, userId); // 编译错误:PostId 不能赋给 UserId
原则 5:接受要宽松,返回要严格(Postel 法则)
函数入参类型应该尽量宽,接受多种合理输入;返回值类型应该尽量窄,给调用者最精确的信息。
// 接受:宽类型(联合 + 可选)
interface RenderOptions {
width: number | string; // 接受 300 或 "300px"
height?: number; // 高度可选
theme?: "light" | "dark"; // 主题可选,默认 light
}
// 返回:窄类型,调用者得到精确信息
interface RenderedOutput {
html: string;
width: number; // 内部规范化为数字,不再是联合类型
height: number; // 内部填充了默认值,不再是 undefined
theme: "light" | "dark"; // 明确的值,不再是 undefined
}
function render(options: RenderOptions): RenderedOutput {
const width = typeof options.width === "string"
? parseInt(options.width, 10)
: options.width;
return {
html: buildHtml(options),
width,
height: options.height ?? 200,
theme: options.theme ?? "light",
};
}
Level 2 · 它是怎么运行的(3-5年经验)
原则 6:类型信息不放在变量名里
userString、parsedUserObject 这类命名把类型信息放进了名字,是因为类型本身不够清晰。好的类型设计让名字只描述"什么",类型描述"是什么形态"。
// 反例:变量名承担了类型的职责
const userJsonString = '{"name":"Alice"}';
const userParsedObject = JSON.parse(userJsonString);
const userValidatedObject = validate(userParsedObject);
// 正解:类型本身就是文档
const rawJson: string = '{"name":"Alice"}';
const parsed: unknown = JSON.parse(rawJson);
const user: User = parseUser(parsed); // parseUser 内部做验证
// 函数名体现转换,变量名体现概念
function parseUser(raw: unknown): User {
if (typeof raw !== "object" || raw === null) {
throw new Error("Invalid user data");
}
const obj = raw as Record<string, unknown>;
if (typeof obj.name !== "string") {
throw new Error("User must have a string name");
}
return { name: obj.name };
}
原则 7:互斥字段用 optional never 而非 boolean
当一个对象的两个字段互斥(要么有 A,要么有 B,不能同时有)时,用 boolean 无法表达这个约束。
// 反例:用两个 boolean 表示互斥状态
interface ButtonProps {
primary?: boolean;
secondary?: boolean; // 和 primary 互斥,但类型没有体现
}
const btn: ButtonProps = { primary: true, secondary: true }; // 荒谬但合法
// 正解:用联合类型 + optional never 表达互斥
interface PrimaryButton {
primary: true;
secondary?: never;
}
interface SecondaryButton {
secondary: true;
primary?: never;
}
type ButtonProps = PrimaryButton | SecondaryButton;
const btn1: ButtonProps = { primary: true }; // OK
const btn2: ButtonProps = { secondary: true }; // OK
const btn3: ButtonProps = { primary: true, secondary: true }; // 编译错误
原则 8:名字反映问题域,而非实现
类型名应该来自业务语言,而不是技术实现。UserMap、PostArray 泄露了实现细节;UserDirectory、Feed 描述的是业务概念。
// 反例:技术术语污染业务类型
type UserMap = Map<string, User>;
type PostArray = Post[];
type ConfigObject = Record<string, unknown>;
function processUserMap(users: UserMap): PostArray { ... }
// 正解:业务语言命名
type UserDirectory = Map<UserId, User>; // "目录",不是"映射"
type Feed = Post[]; // "信息流",不是"数组"
type AppConfig = Record<string, unknown>; // 如果必须用技术名,至少加 App 前缀
function buildFeed(directory: UserDirectory): Feed { ... }
原则 9:不要根据单例数据猜测类型
看到一个 API 返回 { tags: ["ts", "react"] } 就把 tags 类型定为 string[] 是危险的。实际 API 可能返回对象数组、null,或根本不存在这个字段。
// 反例:根据一次观察定义类型
// 实际上这个 API 的 tags 可能是 TagObject[] | string[] | null
interface Article {
title: string;
tags: string[]; // 从一个样本数据推断出来的,可能错
}
// 正解:查阅文档/Schema,用精确或保守的类型
// 如果文档说 tags 可能是字符串数组或 null:
interface Article {
title: string;
tags: string[] | null;
}
// 如果类型不确定,宁可用更宽的类型
interface Article {
title: string;
tags: unknown; // 明确标记"我不确定",强迫调用者做验证
}
原则 10:不精确的类型好过不准确的类型
unknown 比错误的具体类型更安全。string | number 比错误的 string 更安全。宁可让类型宽一点,不要让类型撒谎。
// 反例:类型撒谎(API 响应实际可能有各种结构)
function fetchUser(id: string): Promise<User> {
return fetch(`/api/users/${id}`).then(r => r.json()); // 类型断言了,但没有验证
}
// 如果服务器返回错误格式,运行时崩溃,但编译器沉默
// 正解:承认不确定性,在边界验证
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
const raw: unknown = await res.json(); // 诚实:JSON 反序列化得到 unknown
return parseUser(raw); // 验证后返回 User
}
// 如果验证库支持,更好:
import { z } from "zod";
const UserSchema = z.object({ id: z.string(), name: z.string() });
type User = z.infer<typeof UserSchema>;
async function fetchUser(id: string): Promise<User> {
const raw = await fetch(`/api/users/${id}`).then(r => r.json());
return UserSchema.parse(raw); // 验证失败时抛出有意义的错误
}
原则 11:限制可选属性,优先用必填+默认值
可选属性(?)让对象的每个使用点都要做存在性检查。如果一个属性在逻辑上总是有值(哪怕是默认值),就把它设为必填。
// 反例:大量可选字段让使用方痛苦
interface TableConfig {
pageSize?: number;
sortField?: string;
sortOrder?: "asc" | "desc";
showHeader?: boolean;
striped?: boolean;
}
function renderTable(data: Row[], config: TableConfig) {
const size = config.pageSize ?? 10; // 每次使用都要 ??
const field = config.sortField ?? "id";
const order = config.sortOrder ?? "asc";
const header = config.showHeader ?? true;
const stripe = config.striped ?? false;
// ... 5个地方写 ?? 兜底
}
// 正解:工厂函数生成默认配置,内部类型全必填
interface TableConfig {
pageSize: number;
sortField: string;
sortOrder: "asc" | "desc";
showHeader: boolean;
striped: boolean;
}
// 入参允许部分覆盖
function createTableConfig(overrides: Partial<TableConfig>): TableConfig {
return {
pageSize: 10,
sortField: "id",
sortOrder: "asc",
showHeader: true,
striped: false,
...overrides,
};
}
function renderTable(data: Row[], config: TableConfig) {
// config 里每个字段都有值,无需 ?? 兜底
paginate(data, config.pageSize);
sort(data, config.sortField, config.sortOrder);
}
原则 12:用 readonly 表达意图,防止变异错误
readonly 不只是防御性编程,它是对读者的声明:"这个值在创建后不应该改变。"
// 反例:配置对象可以在任何地方被修改
interface AppConfig {
apiUrl: string;
timeout: number;
retries: number;
}
const config: AppConfig = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3,
};
// 某个函数意外修改了全局配置
function makeRequest(url: string, cfg: AppConfig) {
cfg.timeout = 100; // 静默修改了传进来的配置!
}
// 正解:用 readonly 和 Readonly<T>
interface AppConfig {
readonly apiUrl: string;
readonly timeout: number;
readonly retries: number;
}
// 或者用 Readonly 工具类型
const config: Readonly<AppConfig> = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3,
};
function makeRequest(url: string, cfg: Readonly<AppConfig>) {
cfg.timeout = 100; // 编译错误:Cannot assign to 'timeout' because it is a read-only property
}
// 深度 readonly(对嵌套对象)
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
interface NestedConfig {
db: { host: string; port: number };
}
const cfg: DeepReadonly<NestedConfig> = {
db: { host: "localhost", port: 5432 },
};
cfg.db.host = "remote"; // 编译错误
原则汇总
| # | 原则 | 核心手段 | 错误代价 |
|---|---|---|---|
| 1 | 非法状态无法表达 | 可辨识联合 | 运行时状态矛盾 |
| 2 | 接口联合替代可选字段 | union of interfaces | 字段关系失控 |
| 3 | null 推到边界 | 边界验证 + 内层非空 | null 污染全栈 |
| 4 | 不同概念不同类型 | Branded Types | 参数错位 |
| 5 | 接受宽,返回严 | Postel's Law | 调用方信息不足 |
| 6 | 类型不放变量名 | 正确命名 | 命名腐烂 |
| 7 | 互斥字段用 optional never | never + 联合 | 非法组合可构造 |
| 8 | 名字反映问题域 | 业务命名 | 语义鸿沟 |
| 9 | 不从单例推类型 | 查阅文档/Schema | 类型撒谎 |
| 10 | 不精确好过不准确 | unknown + 验证 | 静默运行时错误 |
| 11 | 限制可选,优先必填 | Partial + 工厂函数 | 使用方重复兜底 |
| 12 | readonly 表达意图 | readonly + DeepReadonly | 意外变异 |
Level 3 · 规范怎么定义的(资深)
本章的 12 条类型设计原则来源于类型理论和实际工程经验的交汇。"让非法状态无法表达" 是 ADT(代数数据类型)设计的核心哲学,在 Elm、Rust、Haskell 中有深入实践。Postel 法则(接受要宽松、返回要严格)在 TypeScript 中通过函数参数使用联合/可选类型、返回值使用精确类型来体现。Branded Types 利用了 TypeScript 结构类型系统的一个技巧——通过交叉一个不可能在运行时存在的属性来打破结构等价,这种做法在其他结构类型语言中也有类似实现。
Level 4 · 边界与陷阱(所有人)
常见陷阱 1:忽略边界情况——在使用本章介绍的特性时,注意处理 null、undefined 和 never 类型的边界情况。TypeScript 的类型系统在这些边界类型上有特殊行为,需要额外注意。
常见陷阱 2:过度使用导致可读性下降——本章的高级特性应该在确实需要时才使用。如果简单的泛型或联合类型能解决问题,不要引入复杂的类型级编程。代码是写给人读的,类型也是。
下一章预告
第 13 章深入 Branded Types 和幽灵类型:原则 4(不同概念不同类型)的完整实现方案,包括如何用工厂函数安全创建 branded 值、幽灵类型模拟状态机、以及货币单位防混淆的真实项目案例。