TypeScript品牌型別實戰:構建型別安全領域模型的6個核心模式

前端工程

型別安全的四大痛點

你定義了UserIdOrderId都是string,結果函式參數傳反了編譯器也不報錯;郵箱地址和普通字串可以互相賦值,執行時才發現格式不對;API回傳的JSON用as User一斷言就完事,型別安全形同虛設;領域模型中DraftOrderApprovedOrder共享同一個介面,狀態轉換全靠自覺。TypeScript的結構化型別系統讓所有string都長得一樣,領域概念在型別層面完全丟失

本文將從核心概念出發,帶你完成基礎Branded Type→Zod驗證整合→智慧建構器→領域模型組合→序列化反序列化→不可變狀態機的6個核心模式,構建編譯時和執行時雙重安全的領域模型。


核心概念

概念 說明
Branded Type 透過交叉型別為原始型別附加品牌標記,實現名義型別效果,如string & { __brand: 'UserId' }
Nominal Type 名義型別系統,型別相容性基於宣告名稱而非結構,TypeScript原生不支援,需透過Branded Type模擬
Structural Type 結構化型別系統,TypeScript預設採用,只要結構相容即可賦值,UserIdOrderId無法區分
品牌符號 品牌型別的唯一識別,通常用__brand_brand等不可達屬性名,執行時不存在
型別收窄 透過型別守衛將寬泛型別縮小為品牌型別,在控制流中精確區分不同品牌
Zod驗證 執行時型別校驗庫,與品牌型別結合實現編譯時+執行時雙重安全
型別守衛 回傳型別謂詞的函式val is BrandType,在執行時檢查的同時收窄編譯時型別
不可變型別 透過readonlyReadonly確保品牌型別實例不可變,防止領域物件被意外修改

品牌型別工作流

編譯時安全:
Branded Type定義 → 智慧建構器(驗證) → 型別守衛(收窄) → 領域模型組合

執行時安全:
Zod Schema驗證 → 建構器校驗 → 序列化/反序列化 → 狀態機約束

端到端安全:
API邊界Zod校驗 → 品牌型別建構 → 業務邏輯型別約束 → 序列化輸出驗證

問題分析:品牌型別的5大挑戰

  1. 結構化型別系統限制:TypeScript採用結構化型別,type UserId = stringtype OrderId = string完全等價,無法在編譯時區分不同領域的同構型別,需要Branded Type模擬名義型別
  2. 執行時與編譯時型別不一致:品牌標記__brand在執行時被擦除,編譯時精確的品牌型別無法保證執行時資料的合法性,API邊界必須配合執行時校驗
  3. 品牌型別序列化困難:JSON序列化時品牌標記遺失,反序列化後需要重新建構品牌型別,網路傳輸和持久化場景需要特殊處理
  4. 泛型約束複雜:品牌型別與泛型結合時約束表達困難,<T extends Brand<string>>的推導和收窄需要精心設計型別關係
  5. 庫型別擴展受限:第三方庫的型別定義無法直接新增品牌標記,需要透過模組增強和型別映射間接擴展

分步實操:6個核心模式

模式1:基礎Branded Type定義與使用

type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type Email = Brand<string, 'Email'>;
type PositiveInt = Brand<number, 'PositiveInt'>;

function createUserId(value: string): UserId {
  return value as UserId;
}

function createOrderId(value: string): OrderId {
  return value as OrderId;
}

const userId: UserId = createUserId('user-001');
const orderId: OrderId = createOrderId('order-001');

// ❌ 編譯錯誤:不能將OrderId賦值給UserId
// const wrong: UserId = orderId;

function getUser(id: UserId): string {
  return `User: ${id}`;
}

getUser(userId);
// ❌ getUser(orderId); // 型別錯誤,OrderId不能賦值給UserId

模式2:品牌型別與Zod驗證整合

import { z } from 'zod';

type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId = Brand<string, 'UserId'>;
type Email = Brand<string, 'Email'>;
type Age = Brand<number, 'Age'>;

const UserIdSchema = z.string().uuid().transform((v) => v as UserId);
const EmailSchema = z.string().email().transform((v) => v as Email);
const AgeSchema = z.number().int().min(0).max(150).transform((v) => v as Age);

function parseUserId(value: unknown): UserId {
  return UserIdSchema.parse(value);
}

function parseEmail(value: unknown): Email {
  return EmailSchema.parse(value);
}

function parseAge(value: unknown): Age {
  return AgeSchema.parse(value);
}

const userId = parseUserId('550e8400-e29b-41d4-a716-446655440000');
const email = parseEmail('user@example.com');
const age = parseAge(25);

// ❌ parseUserId('not-a-uuid'); // ZodError
// ❌ parseEmail('invalid-email'); // ZodError
// ❌ parseAge(-1); // ZodError

const UserSchema = z.object({
  id: UserIdSchema,
  email: EmailSchema,
  age: AgeSchema,
  name: z.string().min(1),
});

type User = z.infer<typeof UserSchema>;

function safeParseUser(data: unknown) {
  return UserSchema.safeParse(data);
}

模式3:品牌型別智慧建構器

type Brand<T, B extends string> = T & { readonly __brand: B };

type Result<T, E = string> =
  | { success: true; value: T }
  | { success: false; error: E };

class BrandConstructor<T, B extends string> {
  constructor(
    private readonly brand: B,
    private readonly validate: (value: T) => boolean,
    private readonly errorMessage: string,
  ) {}

  create(value: T): Brand<T, B> {
    if (!this.validate(value)) {
      throw new Error(`${this.brand}: ${this.errorMessage}`);
    }
    return value as Brand<T, B>;
  }

  safeCreate(value: T): Result<Brand<T, B>> {
    if (!this.validate(value)) {
      return { success: false, error: `${this.brand}: ${this.errorMessage}` };
    }
    return { success: true, value: value as Brand<T, B> };
  }

  isBrand(value: T): value is Brand<T, B> {
    return this.validate(value);
  }

  unwrap(value: Brand<T, B>): T {
    return value as T;
  }
}

type UserId = Brand<string, 'UserId'>;
type Email = Brand<string, 'Email'>;
type PositiveInt = Brand<number, 'PositiveInt'>;

const UserIdBrand = new BrandConstructor<string, 'UserId'>(
  'UserId',
  (v) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v),
  'Invalid UUID format',
);

const EmailBrand = new BrandConstructor<string, 'Email'>(
  'Email',
  (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
  'Invalid email format',
);

const PositiveIntBrand = new BrandConstructor<number, 'PositiveInt'>(
  'PositiveInt',
  (v) => Number.isInteger(v) && v > 0,
  'Must be a positive integer',
);

const userId = UserIdBrand.create('550e8400-e29b-41d4-a716-446655440000');
const emailResult = EmailBrand.safeCreate('user@example.com');
const ageResult = PositiveIntBrand.safeCreate(-5);

if (emailResult.success) {
  console.log(emailResult.value);
}

模式4:領域模型型別安全組合

type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type Money = Brand<number, 'Money'>;
type SKU = Brand<string, 'SKU'>;

interface OrderLine {
  sku: SKU;
  quantity: PositiveInt;
  unitPrice: Money;
}

interface DraftOrder {
  readonly status: 'draft';
  readonly id: OrderId;
  readonly customerId: UserId;
  readonly lines: readonly OrderLine[];
  readonly createdAt: Date;
}

interface ApprovedOrder {
  readonly status: 'approved';
  readonly id: OrderId;
  readonly customerId: UserId;
  readonly lines: readonly OrderLine[];
  readonly approvedBy: UserId;
  readonly approvedAt: Date;
}

interface ShippedOrder {
  readonly status: 'shipped';
  readonly id: OrderId;
  readonly customerId: UserId;
  readonly lines: readonly OrderLine[];
  readonly approvedBy: UserId;
  readonly trackingNumber: string;
  readonly shippedAt: Date;
}

type Order = DraftOrder | ApprovedOrder | ShippedOrder;

function approveOrder(order: DraftOrder, approvedBy: UserId): ApprovedOrder {
  return {
    ...order,
    status: 'approved',
    approvedBy,
    approvedAt: new Date(),
  };
}

function shipOrder(order: ApprovedOrder, trackingNumber: string): ShippedOrder {
  return {
    ...order,
    status: 'shipped',
    trackingNumber,
    shippedAt: new Date(),
  };
}

// ❌ approveOrder(approvedOrder, userId); // 型別錯誤,ApprovedOrder不能賦值給DraftOrder
// ❌ shipOrder(draftOrder, 'tracking'); // 型別錯誤,DraftOrder不能賦值給ApprovedOrder

function getOrderTotal(order: Order): Money {
  const total = order.lines.reduce(
    (sum, line) => sum + (line.unitPrice as number) * (line.quantity as number),
    0,
  );
  return total as Money;
}

模式5:品牌型別序列化與反序列化

type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId = Brand<string, 'UserId'>;
type Email = Brand<string, 'Email'>;

interface SerializableBrand<T, B extends string> {
  serialize(value: Brand<T, B>): T;
  deserialize(raw: unknown): Brand<T, B>;
}

function createSerializableBrand<T, B extends string>(
  brand: B,
  validate: (value: T) => boolean,
): SerializableBrand<T, B> {
  return {
    serialize(value: Brand<T, B>): T {
      return value as T;
    },
    deserialize(raw: unknown): Brand<T, B> {
      if (typeof raw !== 'string' || !validate(raw as T)) {
        throw new Error(`Invalid ${brand} value: ${String(raw)}`);
      }
      return raw as Brand<T, B>;
    },
  };
}

const UserIdSerDe = createSerializableBrand<string, 'UserId'>(
  'UserId',
  (v) => /^[0-9a-f]{8}-[0-9a-f]{4}/i.test(v),
);

const EmailSerDe = createSerializableBrand<string, 'Email'>(
  'Email',
  (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
);

interface UserDTO {
  id: string;
  email: string;
  name: string;
}

interface User {
  readonly id: UserId;
  readonly email: Email;
  readonly name: string;
}

function toDTO(user: User): UserDTO {
  return {
    id: UserIdSerDe.serialize(user.id),
    email: EmailSerDe.serialize(user.email),
    name: user.name,
  };
}

function fromDTO(dto: unknown): User {
  if (typeof dto !== 'object' || dto === null) {
    throw new Error('Invalid user DTO');
  }
  const raw = dto as Record<string, unknown>;
  return {
    id: UserIdSerDe.deserialize(raw.id),
    email: EmailSerDe.deserialize(raw.email),
    name: String(raw.name),
  };
}

const user: User = {
  id: '550e8400-e29b-41d4-a716-446655440000' as UserId,
  email: 'user@example.com' as Email,
  name: 'Alice',
};

const dto = toDTO(user);
const restored = fromDTO(dto);

模式6:不可變品牌型別與狀態機

type Brand<T, B extends string> = T & { readonly __brand: B };

type OrderId = Brand<string, 'OrderId'>;
type UserId = Brand<string, 'UserId'>;

type Draft = 'draft';
type Approved = 'approved';
type Shipped = 'shipped';
type Cancelled = 'cancelled';

interface OrderState<S extends string> {
  readonly _tag: S;
}

interface DraftOrder extends OrderState<Draft> {
  readonly id: OrderId;
  readonly customerId: UserId;
  readonly items: readonly string[];
}

interface ApprovedOrder extends OrderState<Approved> {
  readonly id: OrderId;
  readonly customerId: UserId;
  readonly items: readonly string[];
  readonly approvedBy: UserId;
  readonly approvedAt: Date;
}

interface ShippedOrder extends OrderState<Shipped> {
  readonly id: OrderId;
  readonly customerId: UserId;
  readonly items: readonly string[];
  readonly approvedBy: UserId;
  readonly trackingNumber: string;
  readonly shippedAt: Date;
}

interface CancelledOrder extends OrderState<Cancelled> {
  readonly id: OrderId;
  readonly customerId: UserId;
  readonly reason: string;
  readonly cancelledAt: Date;
}

type AnyOrder = DraftOrder | ApprovedOrder | ShippedOrder | CancelledOrder;

type TransitionMap = {
  draft: { approve: Approved; cancel: Cancelled };
  approved: { ship: Shipped; cancel: Cancelled };
  shipped: {};
  cancelled: {};
};

type NextState<S extends string, E extends string> =
  S extends keyof TransitionMap
    ? E extends keyof TransitionMap[S]
      ? TransitionMap[S][E]
      : never
    : never;

function transition<S extends keyof TransitionMap, E extends keyof TransitionMap[S]>(
  order: AnyOrder & OrderState<S>,
  event: E,
  data: TransitionPayload<S, E>,
): AnyOrder & OrderState<NextState<S, E>> {
  switch (event) {
    case 'approve':
      return {
        ...order,
        _tag: 'approved',
        approvedBy: (data as { approvedBy: UserId }).approvedBy,
        approvedAt: new Date(),
      } as AnyOrder & OrderState<NextState<S, E>>;
    case 'ship':
      return {
        ...order,
        _tag: 'shipped',
        trackingNumber: (data as { trackingNumber: string }).trackingNumber,
        shippedAt: new Date(),
      } as AnyOrder & OrderState<NextState<S, E>>;
    case 'cancel':
      return {
        id: order.id,
        customerId: order.customerId,
        _tag: 'cancelled',
        reason: (data as { reason: string }).reason,
        cancelledAt: new Date(),
      } as AnyOrder & OrderState<NextState<S, E>>;
    default:
      throw new Error(`Invalid event: ${String(event)}`);
  }
}

type TransitionPayload<S extends string, E extends string> =
  E extends 'approve' ? { approvedBy: UserId } :
  E extends 'ship' ? { trackingNumber: string } :
  E extends 'cancel' ? { reason: string } :
  never;

避坑指南

陷阱1:直接用type別名代替品牌型別

// ❌ type別名不建立新型別,UserId和string完全等價
type UserId = string;
type OrderId = string;
const userId: UserId = '123';
const orderId: OrderId = userId; // 編譯透過!型別安全丟失

// ✅ 使用Brand交叉型別建立名義型別
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
const orderId: OrderId = userId as UserId; // ❌ 編譯錯誤

陷阱2:品牌標記使用可達屬性

// ❌ 可達屬性會在執行時產生副作用,影響序列化和比較
type BadBrand<T, B extends string> = T & { brand: B };
const id: BadBrand<string, 'UserId'> = Object.assign('123', { brand: 'UserId' });
JSON.stringify(id); // "123" — 品牌標記遺失

// ✅ 使用不可達的品牌符號屬性
type Brand<T, B extends string> = T & { readonly __brand: B };

陷阱3:跳過執行時驗證直接斷言

// ❌ 直接as斷言繞過驗證,執行時資料可能不合法
const email = 'not-an-email' as Email;

// ✅ 透過建構器或Zod驗證確保執行時合法性
const email = EmailBrand.create('user@example.com');

陷阱4:品牌型別在泛型中遺失品牌資訊

// ❌ 泛型推導時品牌資訊可能被擦除
function first<T>(arr: T[]): T { return arr[0]; }
const ids: UserId[] = [createUserId('1')];
const id = first(ids); // UserId ✓ 但如果泛型約束不當可能退化為string

// ✅ 使用品牌型別作為泛型約束
function firstBrand<T extends Brand<string, string>>(arr: T[]): T { return arr[0]; }

陷阱5:序列化時忘記反序列化驗證

// ❌ 從JSON.parse直接斷言品牌型別,跳過驗證
const user = JSON.parse(json) as User;

// ✅ 透過SerDe反序列化,驗證資料合法性
const user = fromDTO(JSON.parse(json));

報錯排查表

錯誤現象 可能原因 解決方案
Type 'string' is not assignable to type 'Brand<string, "X">' 直接賦值原始值給品牌型別 使用建構器或型別斷言建立品牌型別
Type 'Brand<string, "A">' is not assignable to type 'Brand<string, "B">' 不同品牌型別互相賦值 檢查品牌標記是否一致,確認型別對應關係
Property '__brand' does not exist on type 'string' 對原始型別存取品牌屬性 品牌屬性僅用於編譯時區分,執行時不存在
Zod驗證透過但型別不匹配 z.transform回傳型別與品牌型別不一致 確保transform回撥回傳value as BrandType
序列化後品牌資訊遺失 JSON.stringify忽略不可列舉屬性 品牌標記設計為不可達屬性,序列化只需原始值
泛型中品牌型別推導為unknown 泛型約束缺少品牌型別邊界 使用T extends Brand<string, string>約束
型別守衛不收窄 is謂詞型別與實際品牌型別不一致 確保型別守衛回傳value is BrandType精確匹配
模組增強報__brand不存在 第三方型別定義未宣告品牌屬性 透過declare module擴展型別定義
交叉型別產生never 品牌標記與已有屬性衝突 使用獨特的品牌屬性名如__brand_X避免衝突
執行時型別檢查缺失 僅依賴編譯時品牌型別 在API邊界使用Zod或建構器進行執行時驗證

進階最佳化

品牌型別工具庫

type Brand<T, B extends string> = T & { readonly __brand: B };
type Unbrand<T> = T extends Brand<infer U, string> ? U : T;
type BrandOf<T> = T extends Brand<any, infer B> ? B : never;
type IsBranded<T> = T extends Brand<any, string> ? true : false;
type ReplaceBrand<T, B extends string> = T extends Brand<infer U, string> ? Brand<U, B> : never;
type BrandedKeys<T> = { [K in keyof T]: IsBranded<T[K]> extends true ? K : never }[keyof T];

條件品牌型別

type MaybeBrand<T, B extends string, ShouldBrand extends boolean> =
  ShouldBrand extends true ? Brand<T, B> : T;

type ConfigValue<T, Branded extends boolean = true> = {
  id: MaybeBrand<string, 'ConfigId', Branded>;
  value: T;
  updatedAt: Date;
};

品牌型別與Effect系統整合

type Brand<T, B extends string> = T & { readonly __brand: B };

type Validated<T> = Brand<T, 'Validated'>;
type Sanitized<T> = Brand<T, 'Sanitized'>;

function validate<T>(value: T, predicate: (v: T) => boolean): Validated<T> | Error {
  return predicate(value) ? value as Validated<T> : new Error('Validation failed');
}

function sanitize(input: string): Sanitized<string> {
  return input.replace(/[<>"'&]/g, '') as Sanitized<string>;
}

對比分析

特性 Branded Type Opaque Type io-ts runtypes
實作方式 交叉型別+品牌屬性 條件型別+唯一符號 執行時編解碼器 執行時型別檢查
編譯時安全 ★★★★★ ★★★★★ ★★★★ ★★★★
執行時安全 需配合Zod 需配合Zod ★★★★★ ★★★★★
學習成本
套件體積 0 0 ~15KB ~8KB
序列化支援 需手動實作 需手動實作 ★★★★★ ★★★★
泛型支援 ★★★★ ★★★ ★★★★ ★★★
生態整合 Zod/Effect Zod fp-ts
TypeScript原生
生產推薦 ★★★★★ ★★★★ ★★★ ★★★

品牌型別不是「型別體操」的花架子,而是領域驅動設計的型別安全基石。 它讓UserIdOrderId在編譯時涇渭分明,讓Zod驗證在執行時守衛資料邊界,讓領域模型的狀態轉換在型別層面可驗證。型別安全的最高境界不是寫出最複雜的型別,而是讓非法狀態不可表達


推薦工具

  • JSON格式化 — 格式化API回應JSON,快速排查品牌型別序列化問題
  • 程式碼格式化 — 格式化TypeScript程式碼,統一品牌型別定義風格
  • cURL轉程式碼 — 將API請求轉為型別安全的TypeScript品牌型別fetch程式碼

本站提供瀏覽器本地工具,免註冊即可試用 →

#TypeScript品牌类型#Branded Type#类型安全#Nominal Type#类型编程#2026#前端工程