Menu

TypeScriptのブランド型: 公称型をサンプルで解説

ブランド型は、string & { readonly __brand: "UserId" } のように見えないタグを付けたプリミティブで、OrderId が期待される場所に UserId を渡せなくします。ブランドの仕組み、検証するコンストラクター関数、汎用の Brand ヘルパー、unique symbol のブランド、ブランド付きの数値を解説します。

このページのコードはエディタで実行できます - 編集してすぐに結果を確認できます。

TypeScriptは型を形で比較するので、string の2つのエイリアスは交換可能です。ブランド型は型システムの中にしか存在しないタグ string & { readonly __brand: "UserId" } を加え、UserId を普通の文字列ともほかのすべてのブランドとも互換性のない型にします:

実行時には userId はただの文字列 "u_42" です。ブランドはコンパイル時のラベルで、その役割は ID、単位、検証済みの文字列の取り違えを防ぐことだけです。

問題: エイリアスは名前にすぎない

型エイリアスは新しい型を作りません。既存の型に2つ目の名前を付けるだけで、コンパイラーはどちらの名前も同じものとして扱います:

これが構造的型付けです。TypeScriptは形が合うかをチェックし、string は string に合います。オブジェクトならたいてい形が違いますが、ID、メールアドレス、通貨、単位では形が違うことはありません。ブランドはそのケースだけを解決します。

ブランドの仕組み

string & { readonly __brand: "UserId" } は交差型です。値は文字列であり、さらに "UserId" 型の __brand プロパティも持たなければなりません。本物の文字列はそのプロパティを持たないので、普通の文字列は代入できません:

index.ts(10,10): error TS2345: Argument of type 'string' is not assignable to parameter of type 'UserId'.
  Type 'string' is not assignable to type '{ readonly __brand: "UserId"; }'.
index.ts(11,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"'.

大事な方向は引き続き動きます。UserId は文字列なので、文字列を受け取るものなら何にでも渡せ、.startsWith() を呼んだりテンプレートに入れたりできます。ブランドがふさぐのは入り口だけです。

検証するコンストラクター関数

入り口は as UserId のアサーションだけで、アサーションは何もチェックしません。これを入力を検証する1つの関数に入れておけば、プログラムの中のブランド付きの値はすべて、そのチェックを通ったことがわかります:

sendWelcome は入力を二度とチェックしません。引数の型が、チェックはすでに済んでいると言っているからです。これが「検証ではなくパースせよ(parse, don't validate)」という考え方です。境界でチェックし、その証明を型で持ち運びます。例外より boolean のほうがよければ、型ガード もコンストラクターとして使えます: function isEmail(s: string): s is Email。

汎用の Brand ヘルパー

型ごとに交差型を手で書くのは繰り返しになります。小さなジェネリクスで一度だけ書きましょう:

数値のブランド化も文字列と同じように動きます。amount / 100 は普通の number であることに注意してください。ブランド付きの数値の算術演算はブランドのない結果になります。これについては下で説明します。

unique symbol のブランド

__brand のような文字列のプロパティ名は、本物のプロパティのように見えます。userId.__brand は "UserId" として型チェックを通りますが、実行時には undefined で、2つのライブラリが同じ名前を選ぶかもしれません。unique symbol のキーならどちらも避けられます:

declare const brand: unique symbol は型チェッカーのためだけに存在するシンボルを宣言します。declare キーワードがあるので、これに対するJavaScriptは出力されません。シンボルはそのモジュールから export されていないので、ほかのファイルのコードはブランドのプロパティに名前を付けることすらできず、そのモジュールの外で Meters を得る方法は、export した関数か as Meters のアサーションだけです。

ブランドの実行時のコストはゼロ

コンパイル後の出力にはブランドの痕跡はありません。次は Meters の例で、コンパイラーが追加するモジュールのヘッダーの下に出力される行です。declare、2つの型エイリアス、すべての as は消え、最後の行の抑制された呼び出しはそのまま実行されます。

function toMeters(feet) {
    return (feet * 0.3048);
}
const height = 10;
const inMeters = toMeters(height);
console.log(inMeters.toFixed(3)); // 3.048
// @ts-expect-error: Meters is not Feet
toMeters(inMeters);

ブランド付きの値は普通のプリミティブです。typeof は "string" や "number" を返し、JSON.stringify はいつもどおりに書き出し、比較も以前と同じように動きます。裏を返せば、コンストラクター関数がチェックしない限り、実行時には何もチェックされません。JSON、データベース、URL からパースしたデータは string として届き、その関数を通したときに初めて UserId になります。

算術演算とメソッドはブランドを落とす

ブランド付きの値に対する演算は基本の型を返します。ブランドは + や .slice() が作るものの一部ではないからです:

type Cents = number & { readonly __brand: "Cents" };

const a = 500 as Cents;
const b = 250 as Cents;

const sum = a + b;           // number, not Cents
const total: Cents = a + b;  // error TS2322: Type 'number' is not assignable to type 'Cents'
const fixed = (a + b) as Cents; // re-brand when the result is still valid

たいていはこれが望ましい動きです。セント単位の2つの金額を足すとセントになりますが、セントとセントを掛けてもセントにはならず、どの演算が意味を保つかを知っているのはあなただけです。addCents(a: Cents, b: Cents): Cents のように、コードが必要とする演算の小さなヘルパーを書きましょう。

ブランド型を使う場面

同じプリミティブ型の2つの値を取り違えることが現実のリスクで、ほかにコンパイラーの助けがない場所でブランドを使います:

状況ブランドの例
別々のテーブルの IDUserId、OrderId、ProductId
検証済みの文字列Email、Url、NonEmptyString、Slug
単位と通貨Meters、Feet、Cents、Usd、Eur
サニタイズやエスケープ済みのテキストSafeHtml、SqlIdentifier
範囲のある数値Percentage、PositiveInt

取り違えることのない値や、すでに形の違うオブジェクト型には使う必要はありません。検証ライブラリはスキーマからブランド型を作れます。Zod では z.string().brand<"UserId">() で、parse がブランド付きの UserId を返すスキーマが得られ、コンストラクター関数を手で書く手間が省けます。

よくある質問

TypeScriptのブランド型とは何ですか?

実行時の表現が同じ2つの型を、互いに互換性のない型にするパターンです。基本の型を、普通の値が持っていないタグと交差させます: type UserId = string & { readonly __brand: "UserId" }。すると、普通の文字列や、違うタグを持つ OrderId は、UserId が期待される場所で拒否されます。

TypeScriptに公称型はありますか?

ありません。TypeScriptの型システムは構造的で、同じ形を持つ2つの型は、名前にかかわらず交換可能です。private や #private のメンバーを持つクラスの宣言は公称的に振る舞い、文字列や数値のようなプリミティブで同じ効果を得る一般的な方法がブランド型です。

ブランド型に実行時のコストはありますか?

ありません。ブランドは型の中にしか存在しません。実行時の値は余分なプロパティのない普通の文字列や数値のままで、コンパイル後のJavaScriptはブランドがない場合と同じです。実行時のコードは、ブランド付きの値を作る関数に自分で入れる検証だけです。

ブランド型の値を作るには?

型アサーションで作ります。できれば、先に入力をチェックする1つの小さな関数の中で行います: function toEmail(s: string): Email { if (!s.includes("@")) throw new Error("bad email"); return s as Email; }。as をその1か所にまとめておけば、プログラムの中のすべての Email がチェックを通ったことになります。

型エイリアスとブランド型の違いは何ですか?

type UserId = string は新しい名前にすぎず、UserId が期待される場所ではどんな文字列も受け付けられます。type UserId = string & { readonly __brand: "UserId" } は互換性のない新しい型で、普通の文字列は先にコンストラクター関数かアサーションを通す必要があります。

Coddy programming languages illustration

Coddyでコードを学ぼう

始める