关于 branded-types
面向 TypeScript 开发者的类型设计技能,介绍用品牌类型区分语义不同但底层类型相同的值。它通过类型守卫和验证避免值被混用,且品牌不会带来运行时开销。
主要功能
- 01区分语义不同的原始值
- 02使用类型守卫进行收窄
- 03为单位和标识符添加品牌
- 04跟踪已验证或清理的字符串
- 05避免运行时开销
适用场景
- 01区分绝对路径与相对路径
- 02防止混用不同单位或向量
- 03为用户 ID 与文章 ID 提供类型安全
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ marius-townhouse/ effective-typescript-skills --skill branded-types安装后在 Claude Code 中输入/branded-types确认已加载。
详细介绍
考虑使用品牌实现名义类型
概述
添加幻影类型,以区分语义不同的值。
TypeScript 使用结构类型,但有时你需要名义类型——这些值之所以不同,是因为你声明它们不同,而不是因为它们的形状不同。品牌让你无需增加运行时开销,就能实现这一点。
何时使用此技能
- 区分路径(绝对路径与相对路径)
- 为数字附加单位(米、秒)
- 防止 2D/3D 向量混淆
- 标记已验证或已清理的字符串
- 创建类型安全的标识符
铁律
ALWAYS use brands when primitive types have different semantic meanings.
请记住:
- 品牌是幻影类型(只存在于类型系统中)
- 没有运行时开销
- 强制进行显式转换或验证
- 让无效状态无法表示
检测:“错误的基本类型”问题
当不同的基本类型可能被混淆时:
// ❌ Any string can be passed
function readFile(path: string) { ... }
readFile('foo.txt'); // Relative path - might fail
readFile('/home/foo.txt'); // Absolute path - works
// TypeScript can't tell the difference!
品牌模式
基本品牌结构
type AbsolutePath = string & { _brand: 'abs' };
function isAbsolutePath(path: string): path is AbsolutePath {
return path.startsWith('/');
}
function listAbsolutePath(path: AbsolutePath) {
// Can only be called with validated paths
}
使用品牌类型
function f(path: string) {
// Must check before using
if (isAbsolutePath(path)) {
listAbsolutePath(path); // OK: path is now AbsolutePath
}
listAbsolutePath(path);
// ~~~~ Error: string not assignable to AbsolutePath
}
品牌为何有效
你无法创建一个既是 string 又具有 _brand 属性的值:
type AbsolutePath = string & { _brand: 'abs' };
// This intersection is "impossible" at runtime
// But TypeScript still uses it for type checking
获取 AbsolutePath 的唯一方法是:
- 获得一个
AbsolutePath(由返回此类型的函数提供) - 使用类型守卫进行验证并收窄类型
- 使用类型断言(逃生通道)
常见品牌模式
测量单位
type Meters = number & { _brand: 'meters' };
type Seconds = number & { _brand: 'seconds' };
type MetersPerSecond = number & { _brand: 'm/s' };
const meters = (m: number) => m as Meters;
const seconds = (s: number) => s as Seconds;
function calculateSpeed(distance: Meters, time: Seconds): MetersPerSecond {
return (distance / time) as MetersPerSecond;
}
const d = meters(100);
const t = seconds(10);
const speed = calculateSpeed(d, t); // OK
calculateSpeed(100, 10); // Error: number not assignable to Meters
calculateSpeed(t, d); // Error: can't swap distance and time!
注意事项: 算术运算会丢失品牌:
const doubled = d * 2;
// ^? const doubled: number (brand lost)
已验证的字符串
type SanitizedHTML = string & { _brand: 'sanitized' };
type UserId = string & { _brand: 'userId' };
type Email = string & { _brand: 'email' };
function sanitize(html: string): SanitizedHTML {
// Actually sanitize the HTML
return html.replace(/<script>/g, '') as SanitizedHTML;
}
function setInnerHTML(el: Element, html: SanitizedHTML) {
el.innerHTML = html; // Safe: we know it's sanitized
}
// Can't pass unsanitized strings
setInnerHTML(el, '<script>alert("xss")</script>');
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Error!
// Must sanitize first
setInnerHTML(el, sanitize(userInput)); // OK
类型安全的 ID
type UserId = string & { _brand: 'userId' };
type PostId = string & { _brand: 'postId' };
function getUser(id: UserId): User { ... }
function getPost(id: PostId): Post { ... }
declare const userId: UserId;
declare const postId: PostId;
getUser(userId); // OK
getUser(postId); // Error: PostId not assignable to UserId
已排序的数组
type SortedList<T> = T[] & { _brand: 'sorted' };
function isSorted<T>(xs: T[]): xs is SortedList<T> {
for (let i = 0; i < xs.length - 1; i++) {
if (xs[i] > xs[i + 1]) return false;
}
return true;
}
function binarySearch<T>(xs: SortedList<T>, x: T): boolean {
// Can assume xs is sorted
let low = 0, high = xs.length - 1;
while (high >= low) {
const mid = low + Math.floor((high - low) / 2);
const v = xs[mid];
if (v === x) return true;
[low, high] = x > v ? [mid + 1, high] : [low, mid - 1];
}
return false;
}
const nums = [1, 3, 5, 7, 9];
if (isSorted(nums)) {
binarySearch(nums, 5); // OK: nums is SortedList<number>
}
替代的品牌技术
使用唯一符号(更强)
declare const brand: unique symbol;
type Meters = number & { [brand]: 'meters' };
// Can't be faked because brand isn't exported
在类中使用私有字段
class ValidatedEmail {
private readonly _brand!: 'email';
constructor(public readonly value: string) {
if (!value.includes('@')) throw new Error('Invalid email');
}
}
防止向量混淆
interface Vector2D {
x: number;
y: number;
z?: never; // Explicitly prevent z
}
function norm(v: Vector2D) {
return Math.sqrt(v.x ** 2 + v.y ** 2);
}
const v3d = { x: 3, y: 4, z: 5 };
norm(v3d); // Error: z is incompatible with never
也可以使用品牌:
type Vector2D = { x: number; y: number } & { _brand: '2d' };
type Vector3D = { x: number; y: number; z: number } & { _brand: '3d' };
抵御压力的准则
1. “直接使用类型别名”
压力:“类型别名更简单:type UserId = string”
回应: 类型别名无法防止不同的字符串类型被混用。
行动: 语义上的区别很重要时,请使用品牌。
2. “这会带来运行时开销”
压力:“为基本类型添加属性会占用内存”
回应: 品牌是幻影类型——它们在运行时并不存在。
行动: 尽管使用品牌;它们没有运行时开销。
危险信号——停止并重新考虑
- 存在多种可能混淆的字符串或数字类型
- 函数接受“任意字符串”,却期望特定格式
- 已进行了验证,但类型系统没有记录验证结果
- 交换类型相似的参数导致错误
常见的合理化借口(均不成立)
| 借口 | 事实 |
|---|---|
| “我们会小心” | 错误难免发生,尤其是在大型代码库中 |
| “类型别名就够了” | 别名无法阻止不同类型之间的赋值 |
| “仪式太多” | 能预防难以追查的错误 |
快速参考
// Basic brand pattern
type Brand<T, B extends string> = T & { _brand: B };
type UserId = Brand<string, 'userId'>;
type Meters = Brand<number, 'meters'>;
// Type guard pattern
function isX(val: T): val is BrandedT { ... }
// Factory pattern
const meters = (n: number) => n as Meters;
总结
使用品牌为基本类型赋予语义。
品牌不会增加运行时开销,却能防止混淆底层类型相同的值。可将它们用于 ID、路径、单位、已验证的字符串,以及任何语义区别很重要的基本类型。
参考资料
基于 Dan Vanderkam 的《Effective TypeScript》第 64 条:考虑使用品牌实现名义类型。
评论
支持 Markdown