13.2K Views
May 23, 26
スライド概要
AI時代に考える、 Branded Typesで実現する 堅牢な型付け 2026.05.23 TSKaigi 2026 @yuta-ike エムスリー / エンジニアリンググループ
型エラーが出ると 嬉しいですよね 😉
Q. わたしたちは何のために型を書くのか
Q. わたしたちは何のために型を書くのか A. 型エラーを出すため
Q. どんなときに型エラーが出てほしいだろう
Q. どんなときに型エラーが出てほしいだろう A. 実装が間違っているとき
実装があっていて型チェックも通る 嬉しい
実装が間違っていて型チェックが通らない 正常
実装があっていて、型チェックが通らない 複雑な実装で型推論が追いつかない 止むを得ずimmutableな処理をしている (letの利用、再代入、Record<string, any>)
実装が間違っていて、型チェックが通る 一番避けたい
実装が間違っていて、型チェックが通る 型の表現力には制限があり、 完全に避けることはできない function add(a: number, b: number): number { retrun 0 } ※ 明らかに実装が間違っているが、型レベルで検知はできない(型チェックが通る)
実装が間違っていて、型チェックが通る 型の表現力には制限があり、 完全に避けることはできない それでも、 このパターンは可能な限り小さくしたい
そのためには、 より厳格な型を適用する必要がある。 そのための手段の1つが Branded Types。
目標 Branded Typesを実際のアプリケーションで活用することで、 値の種類や属性・状態を型にエンコードすることができ、 堅牢なプログラミングにつながる ....という感覚を共有したい!
Branded Typesとは type User = { id: string // ユーザーID name: string // 氏名 date: Date // 生年月日 } type Book = { id: string // 本のID name: string // 本のタイトル date: Date // 出版年 } 同じ構造の型がある場合、TypeScriptはこれらを同一の型と みなしてしまう(構造的部分型)
Branded Typesとは type User = { id: string // ユーザーID name: string // 氏名 date: Date // 生年月日 __type: "User" } type Book = { id: string // 本のID name: string // 本のタイトル date: Date // 出版年 __type: "Book" } そこで、型の名前に対応する架空のプロパティを追加すること で、2つの構造を区別することができる(Branded Types)
Branded Typesとは function createUser(user: User) { ... } const book: Book = { id: getUUID() name: "吾輩はエンジニアである", date: new Date(2000, 0, 1) } as Book createUser(book) // TypeError: Book型の変数をUser型には代入できない Branded Typesを使うと2つの型は完全に別の型として扱われるの で、当然代入しようとすると型エラーになる(嬉しい)
プロダクト開発における Branded Types の 使いどころ 例1: ドメインモデルを区別するための利用 UserIDとBookID、UserとBookなどのドメインモデルの区別 例2: 値の状態や属性を表現する Unvalidated, Validatedなど、値の状態や属性を表現する
例1: ドメインモデルを区別する export type User = { userId: string; userName: string; } UserIDとUserNameは本来的には全く別の概念 偶々同じデータ構造(文字列)を持っているだけで、同じ型(string)が割り当 てられる では、我々は何を持ってその値がUserIDであること理解するのか?
ドメインモデルを区別する await getUserById(myUserId); // myUserId はユーザーIDであろう const id = user.id; // ... console.log(id); // この id は、文脈的にユーザーIDっぽい const name = user.id; // 変数名は UserName っぽいが、実体は UserID 変数名から推測する(myUserIdはユーザーIDを表しているに違いない) 周辺の処理・コンテキストから推測する(const id = user.id という処理が あれば、idはユーザーIDに違いない) どちらも人間の認知能力を前提とした曖昧なアプローチ。
ドメインモデルを型レベルで表現する export type UserID = string & { __type: "userID" } const id = user.id; // ^? const id: UserID 型レベルでUserID型であることがわかる(代入しても) const hoge = id; // ^? const hoge: UserID 代入しても情報が失われない updateProfileName(userId); // TypeError: UserID is not UserName UserNameを期待しているところにUserIDを指定すると型エ ラーで落とせる UserIDやUserNameをBranded Typesで定義することで、型システムレベルで ドメインモデルを表現できる。
Branded Typesを利用した ドメインモデルの定義 ドメインの識別を型システムに担保させる → 言い換えると、ビジネスドメインを型に埋め込む行為 変数名やコメントなどの曖昧な表現に依存せずにすむ 代入しても型が失われない
例2: 値の状態や属性を表現する
お題 Githubのようなユーザー名を指定して登録できるサービス
要件: ユーザー名は英数字のみ
① 入力値が string かチェック
② ユーザー名の要件を満たすか
正規表現をチェック
③ DBにユーザー情報を保存
function isValidUserName(input: string) {
return/[^0-9a-zA-Z]/.test(input);
}
app.post(async (c) => {
const input = c.json().userId; // unknown
// inputがstring型であることをチェック
if (typeof input !== "string") {
throw new ValidationError("文字列以外が指定されました");
}
// inputがユーザー名の要件を満たすかチェック
if (isValidUserName(input)) {
throw new ValidationError("利用できない文字が含まれています");
}
// DBに保存
await userRepo.createUser({ id: getUUID(), name: input });
});
お題 Githubのようなユーザー名を指定して登録できるサービス
要件: ユーザー名は英数字のみ
① 入力値が string かチェック
② ユーザー名の要件を満たすか
正規表現をチェック
③ DBにユーザー情報を保存
// input: unknown
① の処理実行
// input: string
app.post(async (c) => {
const input = c.json().userId; // unknown or any
// inputがstring型であることをチェック
if (typeof input !== "string") {
throw new ValidationError("文字列以外が指定されました");
}
お題 Githubのようなユーザー名を指定して登録できるサービス
要件: ユーザー名は英数字のみ
① 入力値が string かチェック
② ユーザー名の要件を満たすか
正規表現をチェック
③ DBにユーザー情報を保存
// input: unknown
① の処理実行
// input: string
② の処理実行
// input: string
型に変化がない
= 型レベルでは何もやって
いないと同じ?
function isValidUserName(input: string) {
return/[^0-9a-zA-Z]/.test(input);
}
if (isValidUserName(input)) {
throw new ValidationError("利用できない文字が含まれています");
}
お題 Githubのようなユーザー名を指定して登録できるサービス
要件: ユーザー名は英数字のみ
① 入力値が string かチェック
② ユーザー名の要件を満たすか
正規表現をチェック
③ DBにユーザー情報を保存
function isValidUserName(input: string) {
return/[^0-9a-zA-Z]/.test(input);
}
if (isValidUserName(input)) {
throw new ValidationError("利用できない文字が含まれています");
}
実装を見るとちゃんと仕事をしている。
でも仕事内容が関数のインタフェースに表出していない
(なぜ?)
お題 Githubのようなユーザー名を指定して登録できるサービス unknown型 ①の バリデーション unknown型 string型
お題 Githubのようなユーザー名を指定して登録できるサービス string型 ②の バリデーション string型 アルファベット+数字型
お題 Githubのようなユーザー名を指定して登録できるサービス string型 ②の バリデーション string型 アルファベット+数字型 これに対応する型が存在しない
お題 Githubのようなユーザー名を指定して登録できるサービス string型 ②の バリデーション string型 アルファベット+数字型 代わりにstring型で代用している
お題 Githubのようなユーザー名を指定して登録できるサービス string型 ②の バリデーション string型 アルファベット+数字型 この小さい長方形の型を作ってあげる → Branded Types
お題 Githubのようなユーザー名を指定して登録できるサービス
今回はValidAsUserNameという名
前で型を定義。
あわせてこの型の値を得るための
ファクトリ関数も定義。
ValidAsUserName型の値は 、ファ
クトリ関数の戻り値からしか得られ
ない
ファクトリ関数の中では必ず英数字
のみの文字列かを検証する(バリ
デーション)
→ ValidAsUserName型は英数字のみ
から成り立つ文字列を表す型である
// Branded Typesの定義
export const ValidAsUserName = string & { __type:
"ValidAsUserName" }
// ValidAsUserNameを得るための関数を定義
export const ValidAsUserName = (input: string) => {
// 関数内でバリデーションを定義
if (/[^0-9a-zA-Z]/.test(input)) {
throw new Error("Invalid")
}
return input as ValidAsUserName
}
お題 Githubのようなユーザー名を指定して登録できるサービス さらに、userRepoは ValidAsUserNameを受け取るように する。 // 元の実装 function createUser( userId: string, userName: string ) { ... } createUser関数の事前条件(引数として与え られる値の範囲)を制限することができ、関数 実装時に考えることが1つ減る。 (= 記号等が含まれるケースを考慮しなくて良 い) // Branded Typesを利用して、受け入れ可能な値をより厳密に表明 function createUser( userId: string, userName: ValidAsUserName ) { ... } そのため、ユニットテストなどを削減できる可 能性がある。
お題 Githubのようなユーザー名を指定して登録できるサービス 要件: ユーザー名はアルファベットと 数字のみ ① 入力値が string かチェック ② ユーザー名の要件を満たすか 正規表現をチェック ③ DBにユーザー情報を保存 // input: unknown ① の処理実行 // input: string ② の処理実行 // input: ValidAsUserName ③の関数はValidAsUserNameのみを 受け入れる → ②の処理を忘れた場合は③の呼び出しで型エラー(嬉しい) → バリデーションをしないと型エラーが出るため、値の検証を強制できる
ValidAsUserName型とは何なのか string型の中でも「英数字のみからなる」という性質を持つ値 言い換えると、「英数字のみからなる」という性質を型で表現している ほかにも、様々な意味を持つ型を定義可能 UserID型は「ユーザーID」という意味を持つ(= 値の意味を型にエン コードする) UnvalidatedUserName型は「検証前のユーザー名」という意味を 持つ(= 値の状態を型にエンコードする)
Branded Typesで型に意味をエンコードする Branded Typesを値に意味を持たせる手段として使う。 変数名やコメントと違い、代入しても追跡が途絶えない。 TypeScriptの型システムのメリットを享受でき、堅牢。 データ構造(string)ではなく、ビジネス上の概念を型にマッピング する。 一部のドメインロジックの検証を型システムに載せるイメージ
実際の開発チームでの活用 ほとんどのEntity + ValueObject、一部のプロパティはBranded Typesで定義 IDやEmailなどPrimary KeyになりうるものはBrand化 一方でそのほかの属性は通常通りプリミティブなtypeで定義 ユーティリティを取っている Branded Typesはzod等の外部ライブラリを使わず自前で定義。データの詰め替え はスプレッド構文を使うとそこまで発生しない。 コードベース全体は、型を全面に押し出したFunctionベースの構成(classはあまり使っ ていない)
現時点での所感 型チェックが通っているならとりあえず安心という感覚がある もちろん全てではないので、テストもちゃんと書く 人間が実装している場合も、型エラーによって処理の漏れや考慮漏れに気づくことが 多く便利 一方で、あくまでランタイムに影響を与えないことを意識することは必要(as等で簡 単に嘘をつける)
まとめ Branded Typesを実際のアプリケーションで活用することで、 値の種類や属性・状態を型にエンコードすることができ、 堅牢なプログラミングにつながる → この堅牢性がAI時代の武器になる(はず)
ちょっとだけ蛇足
なぜTypeScriptを利用するか (私は)ランタイムエラーを防ぐ目的が大きいと思っている ex. オブジェクトだと思ってたらnullだった user.greet() // Uncaught TypeError: Cannot read properties of null TypeScriptはデータ構造を型として扱う ランタイムエラーは防げるが、あくまでデータ構造ベース。 ドメインロジックを検証することはできない
Branded Typesを使う世界線 userIdとuserNameは、データ構造的には同じstring でも実際、我々がそこに見出している意味は異なる。 この意味をそのまま型の世界にエンコードしてあげることで、より堅牢なプロ グラミングが可能になる(はず)
Ask the Speaker or 懇親会でお話しましょう @yuta-ike エムスリー / エンジニアリンググループ