mcpField
B
branded-types

branded-types

github.com/marius-townhouse开发645 次安装39 周前更新
代码仓库

关于 branded-types

面向 TypeScript 开发者的类型设计技能,介绍用品牌类型区分语义不同但底层类型相同的值。它通过类型守卫和验证避免值被混用,且品牌不会带来运行时开销。

主要功能

  1. 01区分语义不同的原始值
  2. 02使用类型守卫进行收窄
  3. 03为单位和标识符添加品牌
  4. 04跟踪已验证或清理的字符串
  5. 05避免运行时开销

适用场景

  1. 01区分绝对路径与相对路径
  2. 02防止混用不同单位或向量
  3. 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 的唯一方法是:

  1. 获得一个 AbsolutePath(由返回此类型的函数提供)
  2. 使用类型守卫进行验证并收窄类型
  3. 使用类型断言(逃生通道)

常见品牌模式

测量单位
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