TypeScript型システムを極限まで使い倒す:Template Literal Typesによる「命名規則のコンパイル時強制」と堅牢なAPI設計
テックリードの私だ。コードレビューをしていて、次のようなコードに遭遇したことはないか?
// どこにでもある「緩い」コード
function fetchUser(userId: string) {
// 実行時までIDのフォーマットが正しいか分からない
if (!userId.startsWith(‘user_’)) {
throw new Error(‘Invalid user ID format’);
}
// 実装…
}
このコードの何が問題か分かるか? 「エラーを検知するタイミングが遅すぎる」点だ。
`userId` が `user_123` なのか、それともうっかりミスで単なる `123` なのか、あるいは別のエンティティのIDである `org_456` なのか——それを判別するのは常に「実行時(Runtime)」になってしまっている。
TypeScriptの真価は、ランタイムのエラーを可能な限りコンパイル時(Design Time)にねじ伏せることにある。今回は、Template Literal Typesを駆使して、特定の命名規則を型レベルで強制し、バグの入り込む余地を完全に排除するプロダクションレベルの設計パターンを伝授する。
—
1. 基礎:Template Literal Typesとは何か
TypeScript 4.1で導入された Template Literal Types は、JavaScriptのテンプレートリテラルの文法をそのまま型空間に持ち込んだものだ。
type Prefix = “user” | “org”;
type IdType = `${Prefix}_${string}`;
// 結果: “user_string” | “org_string” (実際にはstring型は任意の文字列を受け入れる)
しかし、単に `${string}` を使うだけでは甘い。実務では「特定のプレフィックスに加え、その後のフォーマットやプレフィックス自体の網羅性」までコンパイラに検証させる必要がある。
—
2. 実践:厳密なプレフィックスと構造を持つID型の設計
まずは、単なる `string` のエイリアスではなく、ドメイン駆動設計(DDD)における「値オブジェクト(Value Object)」の思想を型レベルで表現したブランテッドタイプ(Branded Types)とTemplate Literal Typesの融合を見てみよう。
以下のコードは、バックエンドのAPIクライアントやルーティングシステムにおいて、不正なIDの混入をコンパイルエラーで弾くための実用的なコードだ。
/
- ブランテッドタイプ用のシンボル
/
declare const __brand: unique symbol;
type Brand
/
- 「user_」で始まり、その後に英数字とアンダースコアのみを許可する型
- ※TypeScript 4.7+ の再帰的・高度な文字列操作型を利用
/
export type UserId = Brand<`user_${string}`, "UserId">;
export type OrgId = Brand<`org_${string}`, "OrgId">;
/
- 実行時バリデーション(Type Guard)
- 型アサーションの安全性を担保するため、ランタイムでも確実にチェックを行う
/
export function isUserId(value: string): value is UserId {
// プレフィックスの検証に加え、英数字のフォーマットなども必要に応じて正規表現で絞り込む
return /^user_[a-zA-Z0-9_]+$/.test(value);
}
export function isOrgId(value: string): value is OrgId {
return /^org_[a-zA-Z0-9_]+$/.test(value);
}
/
- 安全にIDを生成・キャストするファクトリー関数
/
export function parseUserId(value: string): UserId {
if (!isUserId(value)) {
throw new TypeError(`[DomainError]: Invalid UserId format -> “${value}”`);
}
return value; // ここでコンパイラが UserId 型への安全な昇格を認める
}
チーフアーキテクトの視点:なぜブランテッドタイプを組み合わせるのか?
単なる `type UserId = \`user_\${string}\“ だと、TypeScriptの構造的型付け(Structural Typing)により、構造さえ合っていれば通常の文字列や別のID型を代入できてしまう。
ブランテッドタイプ(`Brand
—
3. 応用:複数の命名規則を網羅するイベントハンドラーの型設計
フロントエンド開発でよくあるユースペースとして、イベント名やアクション名がある。「`click:` で始まるUIイベント」や「`api:` で始まる非同期イベント」など、プレフィックスによってペイロードの型を自動的に切り替えたい場面はないだろうか?
ここでもTemplate Literal Typesが圧倒的な力を発揮する。
// 許可するイベントカテゴリの定義
type EventCategory = “ui” | “api” | “system”;
// 厳密なイベント名フォーマットを構築
type AppEventName = `${EventCategory}:${string}`;
// カテゴリに応じたペイロードのマップ
interface EventPayloadMap {
“ui”: { elementId: string; action: “click” | “hover” };
“api”: { endpoint: string; status: number };
“system”: { timestamp: number; code: string };
}
/
- プレフィックスに応じて引数のペイロード型を厳密に縛るイベントディスパッチャ
/
function dispatchEvent
eventName: T,
// 条件付き型(Conditional Types)と推論(infer)を駆使して紐づくペイロードを導出
payload: T extends `${infer Category extends EventCategory}:${string}`
? EventPayloadMap[Category]
: never
): void {
console.log(`[Dispatching]: ${eventName}`, payload);
}
// ==========================================
// 使用例(IDEの補完とエラー検知の確認)
// ==========================================
// ✅ 正しい例:uiイベントにはui用のペイロードが要求される
dispatchEvent(“ui:button”, { elementId: “submit-btn”, action: “click” });
// ✅ 正しい例:apiイベントにはapi用のペイロードが要求される
dispatchEvent(“api:fetch_user”, { endpoint: “/users”, status: 200 });
// ❌ コンパイルエラー:存在しないイベントカテゴリ
// dispatchEvent(“unknown:event”, { foo: “bar” });
// ❌ コンパイルエラー:uiイベントに対してapi用のペイロードを渡しているため型不整合を起こす
dispatchEvent(“ui:modal”, { endpoint: “/users”, status: 200 });
// ➡️ Error: Argument of type ‘{ endpoint: string; status: number; }’ is not assignable to parameter of type ‘{ elementId: string; action: “click” | “hover”; }’.
この実装により、開発者はイベント名をタイポすることも、間違ったペイロードを渡すことも、TypeScriptのコンパイラによって完全に阻止される。テストを書く以前に、IDE上でバグが消滅する感覚を味わえるはずだ。
—
4. パフォーマンス上の注意点:型推論の爆発(Type Instantiation Explosion)を防ぐ
ここで、シニアエンジニアとして伝えなければならない重要な注意点がある。
Template Literal Typesは非常に強力だが、誤った設計をすると TypeScript の型チェッカー(tsc)のメモリを食いつぶし、IDEの補完がフリーズする「Type Instantiation Explosion(型のインスタンス化爆発)」を引き起こす。
❌ 悪い例:ユニオンの掛け合わせによる爆発
type Letters = “a” | “b” | “c” | “d” | “e”; // 5文字
// 5 × 5 × 5 × 5 = 625通りのユニオン型が即座に生成される。
// これが長大な文字列や複数階層になると、コンパイルが数秒〜数分で終わらなくなる。
type BadId = `${Letters}${Letters}${Letters}${Letters}`;
⭕ 良い例:無限の可能性には基本プリミティブ(`string`)を組み合わせる
特定のプレフィックス+可変長文字列(UUIDやランダムハッシュなど)を扱う場合は、全パターンを列挙するのではなく、`${Prefix}_${string}` のようにプリミティブな `string` 型をアンカーとして利用すること。これにより、TypeScriptコンパイラはユニオンの網羅的展開を行わずに済み、パフォーマンスを維持できる。
—
5. まとめ:型は「ドキュメント」であり「防壁」である
今回解説した Template Literal Types による命名規則の強制は、単なるお洒落なテクニックではない。
1. 仕様の自己文書化: コードの型を見るだけで、どのようなフォーマットの文字列が期待されているのかが一目瞭然になる。
2. シフトレフト(Shift Left): 実行時エラーやテストでのみ発覚していたフォーマットミスを、コードを書いている瞬間にIDE上で検知する。
3. リファクタリングの耐性向上: プレフィックスの仕様変更が発生した場合も、型定義を書き換えれば影響範囲がコンパイルエラーとして全自動で炙り出される。
日々の開発において、`string` や `any` といった「思考停止の型」を使う手は今すぐ止めよう。型システムを極限までチューニングし、機械に働かせることこそが、我々プロフェッショナルなエンジニアの仕事なのだから。