- TypeScript
TypeScript ブランド型の作り方|IDの取り違えを型で防ぐ
ブランド型(Branded Type)とは、string や number に「型の上だけの目印」を足して、中身が同じ形でも別の型として区別できるようにする書き方です。 TypeScript は形が同じなら同じ型として扱うため、UserId も OrderId も string の別名にしただけでは区別されません。ブランド型にすると、会員IDを注文IDの引数へ渡した時点で tsc が TS2345 を出して止めてくれます。
この記事では、取り違えが通ってしまう例から始めて、4つの作り方の比較、検証つきの生成関数、実行時の姿、ブランドが外れる場面、Map や Record のキーにしたときの挙動までを、tsc のエラー全文と node の出力で確かめます。
確認環境:Ubuntu 24.04 / TypeScript 7.0.2 / Node.js 22.22.2 / zod 4.6.5。tsconfig は strict: true、target: es2022、module: nodenext です。
ブランド型の早見表
| やりたいこと | 書き方 |
|---|---|
| 汎用の型を1つ用意する | declare const brand: unique symbol; と type Brand<T, B extends string> = T & { readonly [brand]: B }; |
| ID の型を作る | type UserId = Brand<string, "UserId">; |
| 値を検証して作る | asUserId("u-001")(形式が合わなければ例外) |
| 型ガードで絞り込む | if (isUserId(input)) { … } |
| zod で検証と型を同時に作る | z.string().regex(…).brand<"UserId">() |
| 元の string として使う | そのまま渡せる(UserId は string に代入できる) |
4つの作り方の比較です。
| 方式 | 定義の短さ | プロパティとして見えるか | 名前が重なったとき | 実行時の検証 |
|---|---|---|---|---|
| 1. __brand 文字列プロパティ | 1行 | 見える(id.__brand と書ける) | 同じ文字列なら同じ型になる | 自分で書く |
| 2. unique symbol を型ごとに宣言 | 型ごとに2行 | 見えない(TS2339) | 宣言ごとに別の型になる | 自分で書く |
| 3. 汎用 Brand 型 | 最初に2行、以後は型ごとに1行 | 見えない(TS2339) | 同じ文字列なら同じ型になる | 自分で書く |
| 4. zod の .brand | スキーマ1行+型1行 | 見えない(TS2339) | 同じ文字列なら同じ型になる | スキーマが行う |
迷ったら方式3(汎用 Brand 型)に検証つきの生成関数を組み合わせる形が、短さと安全さのバランスがよい選び方です。すでに zod を使っているなら方式4が手軽です。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「TypeScript ブランド型の作り方|IDの取り違えを型で防ぐ」
型エイリアスだけでは ID の取り違えが通る
まず、string に別名を付けただけの例です。
type UserId = string;
type OrderId = string;
function findOrder(orderId: OrderId): string {
return `注文 ${orderId} を検索`;
}
const userId: UserId = "u-001";
console.log(findOrder(userId)); // 会員IDを渡しているのに通る
npx tsc --noEmit の結果はエラー0件で、実行すると次のように出力されます。
注文 u-001 を検索
UserId と OrderId はどちらも string そのものなので、tsc は違いを見分けません。 型エイリアスは「別の名前」を付けるだけで、「別の型」を作るわけではないためです。
ブランド型にすると TS2345 で止まる
string に、型の上だけのプロパティを交差型(&)で足します。
type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };
function findOrder(orderId: OrderId): string {
return `注文 ${orderId} を検索`;
}
const userId = "u-001" as UserId;
const orderId = "o-100" as OrderId;
console.log(findOrder(orderId));
console.log(findOrder(userId));
console.log(findOrder("o-100"));
brand.ts(12,23): error TS2345: Argument of type 'UserId' is not assignable to parameter of type 'OrderId'.
Type 'UserId' is not assignable to type '{ readonly __brand: "OrderId"; }'.
Types of property '__brand' are incompatible.
Type '"UserId"' is not assignable to type '"OrderId"'.
brand.ts(13,23): error TS2345: Argument of type 'string' is not assignable to parameter of type 'OrderId'.
Type 'string' is not assignable to type '{ readonly __brand: "OrderId"; }'.
11行目(正しい注文ID)は通り、12行目(会員IDを渡した)と13行目(ただの文字列を渡した)が止まりました。エラーの最後の行「Type '"UserId"' is not assignable to type '"OrderId"'」が、どの ID とどの ID を取り違えたかをそのまま教えてくれます。
__brand というプロパティは型の上にしかありません。値は "u-001" という文字列のままで、as UserId は「この文字列を UserId として扱う」という宣言です。
ブランド型の作り方4方式
4方式を1ファイルに並べ、__brand へのアクセスと、取り違えたときの挙動を確かめます。
import { z } from "zod";
// 方式1: __brand 文字列プロパティ
type UserId1 = string & { readonly __brand: "UserId" };
// 方式2: unique symbol(型ごとにシンボルを宣言)
declare const userIdBrand: unique symbol;
type UserId2 = string & { readonly [userIdBrand]: never };
// 方式3: 汎用 Brand<T, B>
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };
type UserId3 = Brand<string, "UserId">;
type OrderId3 = Brand<string, "OrderId">;
// 方式4: zod の .brand
const UserIdSchema = z.string().regex(/^u-\d{3}$/).brand<"UserId">();
type UserId4 = z.infer<typeof UserIdSchema>;
declare const a: UserId1;
declare const b: UserId2;
declare const c: UserId3;
declare const d: UserId4;
a.__brand; // 方式1: プロパティ名でアクセスできる
b.__brand;
c.__brand;
d.__brand;
const o: OrderId3 = c;
ways.ts(26,3): error TS2339: Property '__brand' does not exist on type 'UserId2'.
ways.ts(27,3): error TS2339: Property '__brand' does not exist on type 'UserId3'.
ways.ts(28,3): error TS2339: Property '__brand' does not exist on type 'string & $brand<"UserId">'.
ways.ts(30,7): error TS2322: Type 'UserId3' is not assignable to type 'OrderId3'.
Type 'UserId3' is not assignable to type '{ readonly [brand]: "OrderId"; }'.
Types of property '[brand]' are incompatible.
Type '"UserId"' is not assignable to type '"OrderId"'.
- 方式1は a.__brand がエラーにならない。型の上ではプロパティが見えるので、実在しない値を読むコードが書けてしまう(実行時は undefined)
- 方式2〜4は、キーがシンボルなので __brand という名前ではアクセスできない
- zod の .brand が作る型は、エラー文に
string & $brand<"UserId">と表示される
名前が重なったときの違い
方式1で、別々の場所が同じ文字列を目印に使った場合と、方式2の比較です。
// ライブラリAの定義
type UserIdA = string & { readonly __brand: "Id" };
// ライブラリBの定義(たまたま同じ名前)
type GroupIdB = string & { readonly __brand: "Id" };
declare const u: UserIdA;
const g: GroupIdB = u; // エラーにならない
// unique symbol なら宣言ごとに別の型になる
declare const userIdBrand: unique symbol;
declare const groupIdBrand: unique symbol;
type UserId = string & { readonly [userIdBrand]: "Id" };
type GroupId = string & { readonly [groupIdBrand]: "Id" };
declare const u2: UserId;
const g2: GroupId = u2;
collide.ts(16,7): error TS2322: Type 'UserId' is not assignable to type 'GroupId'.
Type 'UserId' is not assignable to type '{ readonly [groupIdBrand]: "Id"; }'.
Property '[groupIdBrand]' is missing in type 'String & { readonly [userIdBrand]: "Id"; }' but required in type '{ readonly [groupIdBrand]: "Id"; }'.
7行目(方式1)はエラーにならず、16行目(方式2)だけが止まりました。unique symbol は宣言ごとに別の型になるので、目印の文字列が同じでも区別されます。 方式3と方式4も、同じ文字列 "Id" を2か所で使うと同じ型になることを、次のコードで確かめました(エラー0件)。
import { z } from "zod";
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };
// 方式3: 別々の場所で同じ名前 "Id" を使った場合
type UserId = Brand<string, "Id">;
type GroupId = Brand<string, "Id">;
declare const u: UserId;
const g: GroupId = u; // エラーにならない
// 方式4: zod でも同じ名前なら同じ型
const A = z.string().brand<"Id">();
const B = z.string().brand<"Id">();
const a = A.parse("x");
const b: z.infer<typeof B> = a; // エラーにならない
1つのアプリの中で名前を管理できるなら方式3で十分です。複数のパッケージにまたがって型を配るときは、方式2が向いています。
検証つきの生成関数(asUserId/isUserId)
as UserId をあちこちに書くと、形式の合わない文字列にもブランドを付けられてしまいます。as を書く場所を生成関数の中だけに閉じ込め、形式の検査を必ず通すのが定番の形です。
declare const brand: unique symbol;
export type Brand<T, B extends string> = T & { readonly [brand]: B };
export type UserId = Brand<string, "UserId">;
export type OrderId = Brand<string, "OrderId">;
const USER_ID = /^u-\d{3}$/;
// 型ガード:true のとき value を UserId として扱える
export function isUserId(value: unknown): value is UserId {
return typeof value === "string" && USER_ID.test(value);
}
// 生成関数:形式が合わなければ例外
export function asUserId(value: string): UserId {
if (!isUserId(value)) {
throw new TypeError(`UserId の形式ではありません: ${JSON.stringify(value)}`);
}
return value;
}
// 例外を使わない版:合わなければ undefined
export function toUserId(value: string): UserId | undefined {
return isUserId(value) ? value : undefined;
}
function findUser(id: UserId): string {
return `会員 ${id} を検索`;
}
console.log(findUser(asUserId("u-001")));
const input: string = "u-777";
if (isUserId(input)) {
console.log(findUser(input)); // if の中では UserId
}
console.log(toUserId("x-1"));
try {
asUserId("o-100");
} catch (e) {
console.log(`${(e as Error).name}: ${(e as Error).message}`);
}
会員 u-001 を検索
会員 u-777 を検索
undefined
TypeError: UserId の形式ではありません: "o-100"
このコードには as UserId が1つもありません。isUserId の戻り値の型 value is UserId(型述語)が、true を返したときに value を UserId として扱ってよいと tsc に伝えているためです。
| 関数 | 合わないとき | 向いている場面 |
|---|---|---|
| isUserId(型ガード) | false を返す | if で分岐したいとき、filter で絞り込むとき |
| asUserId | 例外を投げる | 設定値など、合わなければ処理を止めたいとき |
| toUserId | undefined を返す | 入力フォームなど、合わない値が普通に来るとき |
zod の .brand で検証と型をまとめる
zod を使っている場合は、スキーマに .brand を付けると、parse の結果がブランド型になります。
import { z } from "zod";
const UserIdSchema = z.string().regex(/^u-\d{3}$/).brand<"UserId">();
type UserId = z.infer<typeof UserIdSchema>;
function findUser(id: UserId): string {
return `会員 ${id} を検索`;
}
const id = UserIdSchema.parse("u-001");
console.log(findUser(id), typeof id, JSON.stringify(id));
const r = UserIdSchema.safeParse("x-1");
console.log(r.success, r.success ? "" : r.error.issues[0]?.message);
会員 u-001 を検索 string "u-001"
false Invalid string: must match pattern /^u-\d{3}$/
parse を通していない文字列を渡すと、ほかの方式と同じく TS2345 で止まります。
import { z } from "zod";
const UserIdSchema = z.string().brand<"UserId">();
type UserId = z.infer<typeof UserIdSchema>;
function findUser(id: UserId): void {}
findUser("u-001");
zoderr.ts(5,10): error TS2345: Argument of type 'string' is not assignable to parameter of type 'string & $brand<"UserId">'.
Type 'string' is not assignable to type '$brand<"UserId">'.
ブランドは実行時には残らない
ブランド型の値が実行時にどう見えるかを確かめます。
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };
type UserId = Brand<string, "UserId">;
type Price = Brand<number, "Price">;
const id = "u-001" as UserId;
const price = 1200 as Price;
console.log(typeof id, typeof price);
console.log(JSON.stringify({ id, price }));
console.log(id === "u-001", price === 1200);
console.log(Object.keys(Object(id)));
console.log(id.length, id.toUpperCase(), price.toFixed(1));
string number
{"id":"u-001","price":1200}
true true
[ '0', '1', '2', '3', '4' ]
5 U-001 1200.0
tsc が出力した JavaScript です。
const id = "u-001";
const price = 1200;
console.log(typeof id, typeof price);
…(省略)…
型の宣言と as はすべて消え、ただの文字列と数値が残ります。 そのため次のことが言えます。
- typeof は string・number のまま。JSON.stringify の結果にも目印は入らない
- 実行時のコストは増えない
- 外から来た値(API の応答、フォームの入力、JSON.parse の結果)は、型の上でブランドが付いていても中身の保証にはならない。外部の値は isUserId や zod で検査してからブランドを付ける
- 実行時に「この値は UserId か」を目印から判定することはできない。判定は形式の検査(isUserId)で行う
ブランドが外れる場面と保たれる場面
ブランド型の値に演算やメソッドを使うと、結果は元の string や number に戻ります。
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Price = Brand<number, "Price">;
declare const id: UserId;
declare const orderId: OrderId;
declare const price: Price;
const a: UserId = id + "-copy";
const b: UserId = `${id}`;
const c: UserId = id.toUpperCase();
const d: UserId = id.trim();
const e: Price = price + 100;
const f: Price = price * 2;
const g: Price = Math.round(price);
let h: Price = price;
h += 1;
h++;
const same = id === orderId;
const ids: UserId[] = ["u-001", "u-002"];
const copy: UserId = id;
const arr: UserId[] = [id].map((x) => x);
lose.ts(11,7): error TS2322: Type 'string' is not assignable to type 'UserId'.
Type 'string' is not assignable to type '{ readonly [brand]: "UserId"; }'.
…(12〜17行目も同じ形のエラー)…
lose.ts(19,1): error TS2322: Type 'number' is not assignable to type 'Price'.
Type 'number' is not assignable to type '{ readonly [brand]: "Price"; }'.
lose.ts(21,14): error TS2367: This comparison appears to be unintentional because the types 'UserId' and 'OrderId' have no overlap.
lose.ts(22,24): error TS2322: Type 'string' is not assignable to type 'UserId'.
Type 'string' is not assignable to type '{ readonly [brand]: "UserId"; }'.
lose.ts(22,33): error TS2322: Type 'string' is not assignable to type 'UserId'.
Type 'string' is not assignable to type '{ readonly [brand]: "UserId"; }'.
結果を表にまとめます。
| 操作 | 結果の型 | ブランド |
|---|---|---|
| 文字列連結(id + "-copy") | string | 外れる |
| テンプレートリテラル | string | 外れる |
| toUpperCase・trim | string | 外れる |
| 足し算・掛け算(price + 100、price * 2) | number | 外れる |
| Math.round(price) | number | 外れる |
| 複合代入(h += 1) | number | 外れる(TS2322) |
| インクリメント(h++) | Price のまま | 保たれる(エラーなし) |
| 変数への代入、配列の map でそのまま返す | UserId | 保たれる |
| UserId と OrderId の比較(===) | TS2367 | 取り違えた比較も止まる |
| 文字列リテラルの配列を UserId の配列へ代入 | TS2322 | 要素ごとに生成関数が必要 |
演算した結果は「もう検証済みの値ではない」ので、外れるのは理にかなった動きです。 計算後もブランド型として扱いたいときは、結果をもう一度生成関数に通します。h++ だけはエラーにならなかったので、数値のブランド型をカウンターのように増減させる使い方には向きません。
Map のキー・Record のキーに使ったときの挙動
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
const u1 = "u-001" as UserId;
const o1 = "o-100" as OrderId;
// Map のキー
const names = new Map<UserId, string>();
names.set(u1, "田中");
console.log(names.get(u1), names.get("u-001" as UserId));
names.set(o1, "x");
names.get("u-001");
// Record のキー
const points: Record<UserId, number> = {};
points[u1] = 10;
points[o1] = 20;
points["u-002"] = 30;
const p: number = points[u1];
console.log(points, Object.keys(points));
// Object.keys / Object.entries で取り出したキーは string
for (const key of Object.keys(points)) {
const k: UserId = key;
}
// Set
const seen = new Set<UserId>([u1]);
seen.has(o1);
tsc の出力です。
keys.ts(13,11): error TS2345: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
Type 'OrderId' is not assignable to type '{ readonly [brand]: "UserId"; }'.
Types of property '[brand]' are incompatible.
Type '"OrderId"' is not assignable to type '"UserId"'.
keys.ts(14,11): error TS2345: Argument of type 'string' is not assignable to parameter of type 'UserId'.
Type 'string' is not assignable to type '{ readonly [brand]: "UserId"; }'.
keys.ts(19,1): error TS7053: Element implicitly has an 'any' type because expression of type 'OrderId' can't be used to index type 'Record<UserId, number>'.
keys.ts(20,1): error TS7053: Element implicitly has an 'any' type because expression of type '"u-002"' can't be used to index type 'Record<UserId, number>'.
Property 'u-002' does not exist on type 'Record<UserId, number>'.
keys.ts(26,9): error TS2322: Type 'string' is not assignable to type 'UserId'.
Type 'string' is not assignable to type '{ readonly [brand]: "UserId"; }'.
keys.ts(31,10): error TS2345: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
Type 'OrderId' is not assignable to type '{ readonly [brand]: "UserId"; }'.
Types of property '[brand]' are incompatible.
Type '"OrderId"' is not assignable to type '"UserId"'.
型エラーがあっても tsc は JavaScript を出力するので、そのまま node で実行した結果です。
田中 田中
{ 'u-001': 10, 'o-100': 20, 'u-002': 30 } [ 'u-001', 'o-100', 'u-002' ]
| 使い方 | 型の上での挙動 | 実行時の挙動 |
|---|---|---|
| Map のキー | set・get とも、別のブランドやただの文字列は TS2345 | 中身は文字列なので、同じ文字列なら同じキー |
| Set の要素 | has に別のブランドを渡すと TS2345 | 同上 |
| Record のキー | 別のブランドやただの文字列での添字アクセスは TS7053 | ただのオブジェクトのキーになる |
| Object.keys の結果 | string の配列。UserId へ代入すると TS2322 | 文字列の配列 |
Map と Set はキーの取り違えを TS2345 ではっきり止めてくれるので、ブランド型をキーにするなら Map が扱いやすいです。Record でも止まりますが、エラーは TS7053(暗黙の any)という別の番号になります。Object.keys や Object.entries で取り出したキーは string に戻るので、ループの中で UserId として使うときは isUserId で絞り込みます。
よくある質問
Q. ブランド型と、クラスで ID を包む方法はどう違いますか? A. ブランド型は実行時にただの文字列や数値のままなので、JSON にそのまま出せて、変換のコードが要りません。クラスで包む方法は実行時にも区別できますが、出し入れのたびに変換が必要です。型の上で取り違えを止めたいだけならブランド型が手軽です。
Q. UserId を string の引数に渡せますか? A. 渡せます。UserId は string に目印を足した型なので、string を受け取る関数にはそのまま使えます。逆向き(string を UserId へ)は生成関数を通します。
Q. as UserId と書くのは避けたほうがよいですか? A. 生成関数の中やテストデータなど、場所を決めて使うぶんには問題ありません。あちこちに書くと検査を通っていない値にも目印が付くので、asUserId や isUserId のような入口を用意してそこに集めます。
Q. number にもブランドを付けられますか? A. 付けられます。Brand に number を渡すだけです。足し算や掛け算の結果は number に戻るので、計算後に生成関数へ通す形にします。
まとめ
- 型エイリアス(type UserId = string)は別名を付けるだけで、取り違えは止まらない
- string と目印の交差型にすると、取り違えは TS2345 で止まる。エラーの最後の行に、取り違えた2つの名前が出る
- 作り方は4方式。汎用 Brand 型+検証つき生成関数が基本で、zod を使っているなら .brand が手軽
- __brand 文字列方式はプロパティが型の上で見え、同じ文字列だと同じ型になる。unique symbol を型ごとに宣言すると重ならない
- ブランドは実行時には残らない。typeof も JSON.stringify も元の値のまま。外部の値は検査してから目印を付ける
- 連結・算術・文字列メソッドの結果は string や number に戻る。Map のキーにすると取り違えが TS2345 で止まる
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「TypeScript ブランド型の作り方|IDの取り違えを型で防ぐ」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。