第 12 章

类型设计哲学:合法状态 vs 非法状态,12条原则

第12章:类型设计哲学:合法状态 vs 非法状态,12条原则

理解类型设计哲学是掌握 TypeScript 类型系统的关键一步。

本章核心问题:如何在实际项目中正确使用合法状态 vs 非法状态,12条原则?关键的设计决策和陷阱是什么?

读完本章你将理解


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:不同概念用不同类型

UserIdPostId 运行时都是字符串,但它们是不同的概念。用同一个 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:类型信息不放在变量名里

userStringparsedUserObject 这类命名把类型信息放进了名字,是因为类型本身不够清晰。好的类型设计让名字只描述"什么",类型描述"是什么形态"。

// 反例:变量名承担了类型的职责
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:名字反映问题域,而非实现

类型名应该来自业务语言,而不是技术实现。UserMapPostArray 泄露了实现细节;UserDirectoryFeed 描述的是业务概念。

// 反例:技术术语污染业务类型
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:忽略边界情况——在使用本章介绍的特性时,注意处理 nullundefinednever 类型的边界情况。TypeScript 的类型系统在这些边界类型上有特殊行为,需要额外注意。

常见陷阱 2:过度使用导致可读性下降——本章的高级特性应该在确实需要时才使用。如果简单的泛型或联合类型能解决问题,不要引入复杂的类型级编程。代码是写给人读的,类型也是。


下一章预告

第 13 章深入 Branded Types 和幽灵类型:原则 4(不同概念不同类型)的完整实现方案,包括如何用工厂函数安全创建 branded 值、幽灵类型模拟状态机、以及货币单位防混淆的真实项目案例。

本章评分
4.6  / 5  (26 评分)

💬 留言讨论