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 を得る方法は、次のいずれかです。
- それを返す関数から受け取る
- 型ガードを使って検証し、型を絞り込む
- 型アサーションを使う(最終手段)
よくあるブランドのパターン
測定単位
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>
}
代替のブランド付け手法
Unique Symbol の使用(より強力)
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「Consider Brands for Nominal Typing」に基づいています。