TypeScript

Branded Types(ブランド型)とは?TypeScriptの書き方とブランドが外れる落とし穴

Branded Types(ブランド型)とは?TypeScriptの書き方とブランドが外れる落とし穴

Branded Types(ブランド型)は、同じnumberやstringで表すのに意味が違う値(ユーザーIDと注文ID、円とドル、検証前と検証済みのメールアドレス)を、TypeScriptのコンパイル時に別の型として扱わせる書き方です。基本型に「目印(ブランド)」のプロパティを交差させるだけで、取り違えが型エラーになります。

この記事では、書き方3通りに加えて、ブランドが外れる・漏れる場面(算術演算、オブジェクトのスプレッド、.d.ts経由の利用)をTypeScript 7.0.2で実測した結果と、Zod・Valibot・Effect・type-festのどれを使うかの判断までを扱います。コード例はすべてTypeScript 5.9.3・6.0.3・7.0.2の3版で型チェックし、エラーの出方が3版で一致することを確かめています。

まとめ:Branded Typesの要点

  • 目的:構造的部分型では素通りする「形は同じで意味が違う値」の取り違えを、コンパイル時に止めます。実行時のコストはゼロです。
  • 書き方:最短はnumber & { __brand: "UserId" }。複数の型に使うならBrand<T, B>で共通化し、別ライブラリとの衝突まで防ぐならunique symbolをキーにします。
  • 外れる場面:userId + 1の結果はただのnumberです。オブジェクト型はスプレッドした結果の型にブランドが残り、privateで隠したブランドは.d.tsで型が消えます。
  • 生成口:asは中身を検査しないので、検証付きのファクトリ関数かZod・Valibotのbrandを通した値にだけブランドを付けます。
  • 使う範囲:ID・通貨・単位・検証済みトークンのように、取り違えが実害になる値に絞ります。

Branded Typesで防げる取り違え:構造的部分型の穴

TypeScriptの型の互換性は、公式HandbookのType Compatibilityに「Type compatibility in TypeScript is based on structural subtyping.」とあるとおり、構造(形)で判定します。型エイリアスに別の名前を付けても中身がnumberなら同じ型なので、次の取り違えは型エラーになりません。

type UserId = number;
type OrderId = number;

function cancelOrder(id: OrderId) {}
const userId: UserId = 42;
cancelOrder(userId); // 通ってしまう(どちらも number)

ブランドを付けると、同じnumberでも__brandの値が違う別の型になります。TypeScript 7.0.2では次のエラーが出ます。

type UserId = number & { __brand: "UserId" };
type OrderId = number & { __brand: "OrderId" };

function cancelOrder(id: OrderId) {}
const userId = 42 as UserId;
cancelOrder(userId);
// error TS2345: Argument of type 'UserId' is not assignable to parameter of type 'OrderId'.
//   Types of property '__brand' are incompatible.
//     Type '"UserId"' is not assignable to type '"OrderId"'.
cancelOrder(42);
// error TS2345: Argument of type 'number' is not assignable to parameter of type 'OrderId'.

__brandは型の上にだけある目印で、コンパイル後のJavaScriptにはconst userId = 42;しか残りません。as UserIdも消えるので、メモリにも速度にも影響しません。

この手法はTypeScriptのコンパイラ自身も使っています。v6.0.3のsrc/compiler/types.tsの23行目はexport type Path = string & { __pathBrand: any; };で、正規化済みのファイルパスと生の文字列を型で分けています。

Branded Typesの書き方3通り

交差型による__brandの直接付与

最短の書き方です。値はasで作ります。1〜2個の型を区別したいだけならこれで足ります。

type Email = string & { __brand: "Email" };
const email = "[email protected]" as Email;

Brand<T, B>ユーティリティによる型定義の共通化

ブランドを付けた型が増えるなら、ジェネリクスの型引数でブランド名を差し込むユーティリティにまとめます。1つの型が1行で済み、readonlyで目印の書き換えも防げます。

type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId = Brand<number, "UserId">;
type OrderId = Brand<number, "OrderId">;
type Email = Brand<string, "Email">;

unique symbolによるブランドキーの衝突防止

文字列キーの__brandには弱点があります。別々のライブラリが同じキーと同じ名前でブランドを定義すると、2つの型は構造が一致して互換になります。実測では、次のtoBへの代入はエラーになりませんでした。

type LibA_UserId = string & { __brand: "UserId" };
type LibB_UserId = string & { __brand: "UserId" };
const fromA = "u1" as LibA_UserId;
const toB: LibB_UserId = fromA; // エラーにならない

キーをunique symbolにすると衝突しなくなります。unique symbolはTypeScript 2.7のリリースノートで導入された型で、「Each reference to a unique symbol implies a completely unique identity that’s tied to a given declaration.」とあるとおり、宣言ごとに別の型になります。declare constで宣言するだけなので、実行時の値は作られません。

declare const brandA: unique symbol;
declare const brandB: unique symbol;
type SA = string & { readonly [brandA]: "UserId" };
type SB = string & { readonly [brandB]: "UserId" };

const sa = "u1" as SA;
const sb: SB = sa;
// error TS2322: Type 'SA' is not assignable to type 'SB'.
//   Property '[brandB]' is missing in type ...

共通化と組み合わせるなら、シンボルを1つ宣言し、ブランド名は値の側で区別します。シンボルをモジュールからexportしなければ、外のコードは同じキーを書けません。ライブラリとして配布する型や、複数チームが同じリポジトリでブランドを定義するコードでは、最初からこの形にしておくのが安全です。

declare const brand: unique symbol;
export type Brand<T, B extends string> = T & { readonly [brand]: B };

ブランドが外れる・漏れる場面

Branded Typesは型の上の目印なので、値を加工したり型情報を書き出したりする途中で外れることがあります。ここで挙げる3つは、いずれもTypeScript 5.9.3・6.0.3・7.0.2で同じ結果でした。

算術演算によるブランドの消失

ブランド付きの値はnumberとして計算に使えますが、結果の型はnumberに戻ります。同じ通貨どうしの足し算でも、ブランドは残りません。

type JPY = Brand<number, "JPY">;
const a = 100 as JPY;
const b = 200 as JPY;
const total: JPY = a + b;
// error TS2322: Type 'number' is not assignable to type 'JPY'.

// 演算は関数に閉じ込め、戻り値でブランドを付け直す
function addJPY(x: JPY, y: JPY): JPY {
  return (x + y) as JPY;
}

通貨や単位にブランドを付けるなら、足し算や換算を行う関数を同じモジュールに用意します。呼び出し側にas JPYを書かせると、ドルとの足し算も通ってしまいます。

オブジェクトのスプレッド後に残るブランド型

オブジェクトにブランドを付けた型では、スプレッドした結果の推論型にもブランドのプロパティが残ります。実行時のオブジェクトにブランドのプロパティがあるわけではなく、型の上で目印が引き継がれるという意味です。TSKaigi 2026の発表「スプレッド構文によるブランド流出問題を乗り越えて、オブジェクト型に対する Branded Types を使い倒す」が指摘した問題で、実測でも検証を通らない値が作れました。

declare const drBrand: unique symbol;
type DateRange = { start: Date; end: Date } & { readonly [drBrand]: unknown };

function toDateRange(start: Date, end: Date): DateRange {
  if (start > end) throw new Error("start > end");
  return { start, end } as DateRange;
}

const valid = toDateRange(new Date("2026-01-01"), new Date("2026-01-02"));
// start が end より後でもエラーにならない
const invalid: DateRange = { ...valid, start: new Date("2026-02-01") };

同じ発表のスプレッド対策は、ブランドをクラスの非公開メンバーに置くことです。TypeScriptが推論するスプレッド結果の型には非公開メンバーが含まれないため、このブランドが落ちて型エラーになります。declare classなので実行時のクラスは作られません。ただし防げるのはスプレッドによる複製だけで、startとendをreadonlyにしても、valid.start.setFullYear(2027)のようなDate自体の書き換えは止められません。検証済みの条件を保ちたいなら、日付を数値や文字列で持つなど、変更経路も合わせて閉じます。

declare class Brand<T extends symbol> {
  protected readonly __brand: { readonly [K in T]: unknown };
}
declare const dateRange: unique symbol;
type DateRange = { readonly start: Date; readonly end: Date } & Brand<typeof dateRange>;

function toDateRange(start: Date, end: Date): DateRange {
  if (start > end) throw new Error("start > end");
  return { start, end } as DateRange;
}

const valid = toDateRange(new Date("2026-01-01"), new Date("2026-01-02"));
const invalid: DateRange = { ...valid, start: new Date("2026-02-01") };
// error TS2322: Type '{ end: Date; start: Date; }' is not assignable to type 'DateRange'.
//   Property '__brand' is missing in type ...

.d.ts出力によるprivateメンバーの型情報の消失

上のdeclare classでprotectedを使っているのには理由があります。privateにすると、declaration: trueで書き出した.d.tsからメンバーの型が消えます。7.0.2の出力は次のとおりです。

// private の場合
export declare class BrandPriv<T extends symbol> {
    private readonly __brand;
}
// protected の場合
export declare class BrandProt<T extends symbol> {
    protected readonly __brand: {
        readonly [K in T]: unknown;
    };
}

この.d.tsを別のプロジェクトから読み込んで試すと、private版は別々のシンボルで作った2つのブランド型が互いに代入できてしまい、protected版だけがエラーを出しました。同じソース内では両方ともエラーになるので、パッケージに切り出したときに初めて区別が消えます。TypeScript側ではissue #38953として報告され、Design Limitation(設計上の制約)のラベルで閉じられています。ブランド型を含む型をnpmや社内パッケージで配る場合はprotectedを使います。

段階付きブランド:未検証と検証済みの型の区別

ブランドの中身をオブジェクトにすると、複数の目印を重ねられます。次のVerifiedEmailはEmailの目印も持つので、Emailを受け取る関数にはそのまま渡せます。逆に、確認前のEmailをVerifiedEmailが要る関数に渡すとエラーです。

type Brand<T, B extends string> = T & { readonly __brand: { [K in B]: true } };
type Email = Brand<string, "Email">;
type VerifiedEmail = Brand<string, "Email" | "Verified">;

declare function send(to: Email): void;
declare function sendInvoice(to: VerifiedEmail): void;

const e = "[email protected]" as Email;
const v = "[email protected]" as VerifiedEmail;
send(v);         // OK
sendInvoice(e);
// error TS2345: ... Property 'Verified' is missing in type '{ Email: true; }'

「確認メールを踏んだアドレスにだけ請求書を送る」「権限チェック済みのIDだけ削除APIに渡す」のように、処理の段階を型で表したいときに使えます。Zod 4のz.$brandやtype-festのTaggedも、中身を同じオブジェクト形式で持っています。

構造的部分型と名義型の違い

型の互換性の判定には2つの系統があります。TypeScriptは構造的部分型を採り、JavaやC#は型の宣言とその継承・実装関係で判定する名義型(nominal typing)を採ります。Branded Typesは、構造的部分型のまま必要な型にだけ名義型に近い区別を持ち込む手法です。

観点 構造的部分型(TypeScript) 名義型(Java・C#)
型互換性の判定基準 必要なメンバーの型が適合 型の宣言と継承・実装関係
同じ形の独立した型宣言 構造が適合すれば互換 継承等の関係がなければ非互換
異なる型の間の代入 構造上の互換性で判定 継承等による暗黙の変換も可能
Branded Typesの役割 必要な型だけ区別を足す 不要

名義型をTypeScriptに組み込む提案は、2014年7月に立ったissue #202「Support some non-structural (nominal) type matching」が今も続いています。2026年10月30日時点でopenのままで、ラベルはSuggestionとIn Discussionです。TypeScript 7.0(2026年7月8日発表のGoによるネイティブ移植版)でも専用の構文は入っていないので、当面はブランドで代用することになります。

asの落とし穴と実行時の検証

ブランド型の弱点は、値を作るときにasで「これはUserIdだ」と言い切る点です。asは値を検査しないので、-1 as UserIdもコンパイルを通ります。API・DB・フォームなど外から来る値は、検証してからブランドを付けます。

ファクトリ関数へのブランド生成処理の集約

asを書く場所を、検証を通したあとの1行だけにします。ほかの場所でas UserIdを見つけたらレビューで止める、という運用まで決めておくと効果が残ります。

function toUserId(n: number): UserId {
  if (!Number.isInteger(n) || n <= 0) {
    throw new Error("invalid UserId");
  }
  return n as UserId;
}

型ガードによるUserIdへの絞り込み

例外を投げずに分岐したいときは、is述語の型ガードを使います。trueを返した側の分岐でだけUserIdとして扱えます。

function isUserId(v: unknown): v is UserId {
  return typeof v === "number" && Number.isInteger(v) && v > 0;
}

// 型ガードで絞り込めば as なしで UserId を返せる
function parseUserId(v: unknown): UserId {
  if (!isUserId(v)) throw new Error("invalid UserId");
  return v;
}

Zod・Valibotによるスキーマ検証とブランド付与

JSONの受け取り口では、スキーマ検証ライブラリのbrandを使うと、検証とブランド付けが1つの定義で済みます。バリデーションの層をすでにZodやValibotで書いているなら、追加の手間はほとんどありません。

import * as z from "zod";

const UserIdSchema = z.number().int().positive().brand<"UserId">();
type UserId = z.infer<typeof UserIdSchema>; // number & z.$brand<"UserId">

const id = UserIdSchema.parse(42);           // 42(実行時は素のnumber)
UserIdSchema.safeParse(-1).success;          // false

Zodの公式ドキュメントは「Note that branded types do not affect the runtime result of .parse. It is a static-only construct.」と書いており、実測でもparse(42)の結果はtypeofがnumber、JSON.stringifyしても42のままでした。ブランドは型の上にしか無いので、JSONで送った値は受け取った側でもう一度parseしない限りブランドを持ちません。

Zod 4.6.7の.brand()は、既定では出力の型にだけブランドを付けます(z.inputは素のnumberのまま)。入力側にも付けたいときは、Zod 4.2以降で使える2つめの型引数で"in"または"inout"を指定します。

import * as v from "valibot";

const EmailSchema = v.pipe(v.string(), v.email(), v.brand("Email"));
type Email = v.InferOutput<typeof EmailSchema>; // string & v.Brand<"Email">

v.parse(EmailSchema, "[email protected]"); // "[email protected]"
v.safeParse(EmailSchema, "nope").success; // false

Valibotのbrandはパイプラインの1段として書き、前段の検証を通った値の出力型にブランドを付けます。ZodとValibotを両方受け付けるAPIを作るなら、Standard Schema経由で受け取れば、どちらのスキーマから出たブランド型もそのまま扱えます。

ライブラリ4種の選び分け

自前のBrand型で足りる場面も多いですが、検証まで含めるならライブラリの機能を使うほうが書く量が減ります。次の表は2026年10月30日時点のnpmの最新版で確認した内容です。

ライブラリ 確認した版 ブランドを付けるAPI 実行時の検証
Zod 4.6.7 .brand<"名前">() あり(スキーマ)
Valibot 1.5.0 v.brand("名前") あり(前段のアクション)
Effect 4.1.0 Brand.nominal / Brand.make nominalは無し・makeは有り
type-fest 5.10.0 Tagged<型, "名前"> 無し(型のみ)
  • Zod・Valibot:外部入力の検証をすでにこのどちらかで書いているなら第一候補です。ブランドを付ける場所がスキーマ定義に限られ、asが散らばりません。
  • Effect:4.1.0のBrand.nominalは検証せずに値を返すコンストラクタ、Brand.makeは条件を満たさない値でBrandErrorを投げるコンストラクタを作ります(実測で-1を渡すとBrandError)。Effect 3系の公式ガイドにあるBrand.refinedは4.1.0のBrandモジュールに無く、4系のガイドでは検証付きのコンストラクタをBrand.makeで作るので、3系の記事のコードはそのまま動きません。Effectでアプリを組んでいる場合だけ選びます。
  • type-fest:型だけ欲しい場合の選択肢です。5.10.0のtagged.d.tsには「The Opaque type is deprecated in favor of Tagged.」とあり、Opaqueは非推奨です。元の型へ戻すときはUnwrapTaggedを使います。

導入すべき場面と避ける場面

取り違えが実害に直結する値にだけ付け、それ以外には付けません。全部のstringにブランドを付けると、テストデータの作成や外部ライブラリとの受け渡しのたびに変換が要り、肝心のasの監視が埋もれます。

導入が効く場面:

  • UserId・OrderId・ProductIdのように、同じnumberやstringのIDが1つの関数の引数に並ぶ。
  • JPYとUSD、メートルとフィートを数値で持ち、換算漏れが請求や計測の誤りになる。
  • 生のパスワードとハッシュ済みの値、未検証と検証済みの入力を型で分けたい。

避ける場面:

  • その値が1つのモジュールの中でしか使われず、取り違える経路が無い。
  • 外部ライブラリとの間で値を頻繁に往復させ、変換の記述が増えるだけになる。
  • 値に振る舞い(計算や整形のメソッド)を持たせたい。この場合はDDDの値オブジェクトとしてクラスで包むほうが、検証と振る舞いを1か所にまとめられます。

よくある質問

Branded Typesは実行時のパフォーマンスに影響しますか?

影響しません。__brandもasもコンパイル後のJavaScriptには残らず、値は素のnumberやstringのままです。Zodの.brand()も型だけの仕組みで、parseの結果は変わりません。

__brandとunique symbolのどちらを使えばよいですか?

1つのアプリの中で使うなら文字列キーの__brandで足ります。別のライブラリが同じ__brandと同じ名前を使うと互換になるので、パッケージとして配る型や複数チームで定義する型にはunique symbolを使います。

TypeScriptに名義型の機能はありますか?

任意の型を名義型として宣言する専用の構文はありません。2014年に立ったissue #202は2026年10月30日時点でもopenで、TypeScript 7.0にも入っていません。ただし、クラスのprivate・protectedメンバーやunique symbolは宣言ごとに区別されるので、Branded Typesはこれらを使って名義型に近い区別を付けます。

asを使わずにブランド付きの値を作れますか?

作れます。自前の型でも、v is UserIdの型ガードで絞り込んだ値はそのままUserIdとして返せます。ただし型ガードの判定が正しいかはコンパイラが確かめないので、条件はasと同じ重さでレビューします。Zod・ValibotのbrandやEffectのBrand.makeに任せれば、検証の条件もライブラリ側の定義にまとまります。

ブランドを付けた値は元の型として使えますか?

使えます。UserIdはnumberとの交差型なので、算術やログ出力にそのまま渡せます。ただし計算結果はブランドの付かないnumberになるので、同じブランドのまま返したいときは演算用の関数で付け直します。

関連記事

お気に入りに入れた記事の一覧

一創の公式 SNS

X では毎朝の新着記事と IT ニュースの速報を、YouTube・TikTok・Instagram・LinkedIn・Facebook では記事の解説動画を配信しています。

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.10.29 テックブログ ソニー生命の不正アクセス:38.5万件の対象確認と窓口、停止中の手続き
  2. 2026.10.31 テックブログ 松屋の不正アクセス:237万件の対象確認と窓口、漏れたパスワードの守り方
  3. 2026.10.31 テックブログ 無印良品の情報漏洩:MUJIアプリ最大2,726万件の対象確認となりすましメール対策
  4. 2026.10.27 テックブログ Joshin(ジョーシン)不正アクセス:最大600万件の対象とサブスク初期パスワードの対処
  5. 2026.10.28 テックブログ リンツ不正アクセス:314万件の対象確認とパスワード強制リセット後の対処

RELATED POSTS 関連記事

目次