TypeScript品牌型別實戰:構建型別安全領域模型的6個核心模式
前端工程
型別安全的四大痛點
你定義了UserId和OrderId都是string,結果函式參數傳反了編譯器也不報錯;郵箱地址和普通字串可以互相賦值,執行時才發現格式不對;API回傳的JSON用as User一斷言就完事,型別安全形同虛設;領域模型中DraftOrder和ApprovedOrder共享同一個介面,狀態轉換全靠自覺。TypeScript的結構化型別系統讓所有string都長得一樣,領域概念在型別層面完全丟失。
本文將從核心概念出發,帶你完成基礎Branded Type→Zod驗證整合→智慧建構器→領域模型組合→序列化反序列化→不可變狀態機的6個核心模式,構建編譯時和執行時雙重安全的領域模型。
核心概念
| 概念 | 說明 |
|---|---|
| Branded Type | 透過交叉型別為原始型別附加品牌標記,實現名義型別效果,如string & { __brand: 'UserId' } |
| Nominal Type | 名義型別系統,型別相容性基於宣告名稱而非結構,TypeScript原生不支援,需透過Branded Type模擬 |
| Structural Type | 結構化型別系統,TypeScript預設採用,只要結構相容即可賦值,UserId和OrderId無法區分 |
| 品牌符號 | 品牌型別的唯一識別,通常用__brand或_brand等不可達屬性名,執行時不存在 |
| 型別收窄 | 透過型別守衛將寬泛型別縮小為品牌型別,在控制流中精確區分不同品牌 |
| Zod驗證 | 執行時型別校驗庫,與品牌型別結合實現編譯時+執行時雙重安全 |
| 型別守衛 | 回傳型別謂詞的函式val is BrandType,在執行時檢查的同時收窄編譯時型別 |
| 不可變型別 | 透過readonly和Readonly確保品牌型別實例不可變,防止領域物件被意外修改 |
品牌型別工作流
編譯時安全:
Branded Type定義 → 智慧建構器(驗證) → 型別守衛(收窄) → 領域模型組合
執行時安全:
Zod Schema驗證 → 建構器校驗 → 序列化/反序列化 → 狀態機約束
端到端安全:
API邊界Zod校驗 → 品牌型別建構 → 業務邏輯型別約束 → 序列化輸出驗證
問題分析:品牌型別的5大挑戰
- 結構化型別系統限制:TypeScript採用結構化型別,
type UserId = string和type OrderId = string完全等價,無法在編譯時區分不同領域的同構型別,需要Branded Type模擬名義型別 - 執行時與編譯時型別不一致:品牌標記
__brand在執行時被擦除,編譯時精確的品牌型別無法保證執行時資料的合法性,API邊界必須配合執行時校驗 - 品牌型別序列化困難:JSON序列化時品牌標記遺失,反序列化後需要重新建構品牌型別,網路傳輸和持久化場景需要特殊處理
- 泛型約束複雜:品牌型別與泛型結合時約束表達困難,
<T extends Brand<string>>的推導和收窄需要精心設計型別關係 - 庫型別擴展受限:第三方庫的型別定義無法直接新增品牌標記,需要透過模組增強和型別映射間接擴展
分步實操: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原生 | 是 | 是 | 否 | 否 |
| 生產推薦 | ★★★★★ | ★★★★ | ★★★ | ★★★ |
品牌型別不是「型別體操」的花架子,而是領域驅動設計的型別安全基石。 它讓
UserId和OrderId在編譯時涇渭分明,讓Zod驗證在執行時守衛資料邊界,讓領域模型的狀態轉換在型別層面可驗證。型別安全的最高境界不是寫出最複雜的型別,而是讓非法狀態不可表達。
推薦工具
本站提供瀏覽器本地工具,免註冊即可試用 →
#TypeScript品牌类型#Branded Type#类型安全#Nominal Type#类型编程#2026#前端工程