こんにちは。テクニカルリードの私だ。
今日のコードレビューで、また「文字列のバリデーションをランタイムの`.match()`や`if`文の山に頼っている」コードを見かけた。
「動的に渡される文字列のフォーマットを保証したい」
「特定のプレフィックスを持つ識別子だけをコンポーネントのPropsやAPIクライアントのキーとして受け付けたい」
こうした要件に直面したとき、君たちは反射的に実行時チェックを書いていないか?
断言しよう。文字列の形状に関する制約は、TypeScriptの型システムにコンパイル時に検証させるべきだ。
今回は、Template Literal Types(テンプレートリテラル型)を関数型(Function Types)の引数に極限まで応用し、ランタイムのオーバーヘッドをゼロに抑えつつ、IDEの補完と静的解析を完璧に味方につける動的バリデーションの実装パターンを伝授する。
—
なぜランタイムバリデーションだけでは不十分なのか
フロントエンドのコンポーネント設計や、複雑な非同期APIクライアントを構築する際、私たちはしばしば次のような「特定のプレフィックス(例: `data-` や `id-`、あるいは特定のドメインイベント名)を持つ文字列」を引数として扱う。
// よくあるアンチパターン
function registerEvent(eventName: string) {
if (!eventName.startsWith(‘evt:’)) {
throw new Error(‘Invalid event name’); // 実行時エラー
}
// …
}
このアプローチには2つの致命的な欠陥がある。
1. バグの発見が遅い: 実際にそのコードパスが実行される(あるいはテストが走る)まで、タイポや不正な文字列の混入に気づけない。
2. DX(開発者体験)の欠如: 開発者が「何を渡すべきか」をIDEが教えてくれない。エディタの補完が効かない文字列の入力は、エンジニアの認知負荷を高めるだけのノイズだ。
TypeScript 4.1以降で導入された Template Literal Types を使えば、型レベルで文字列のパターンの合致を強制できる。これを関数シグネチャに組み込むことで、「コンパイルエラーになるコードは、物理的に実行できない」 という堅牢な世界を構築できるのだ。
—
プロダクションコード:Template Literal Typesによる厳格な引数バリデーション
それでは、実際のプロダクションコードを見ていこう。
今回は、特定のプレフィックス(`action:`)を持ち、かつコロンで区切られたドメイン名と操作名を受け付ける堅牢な関数型システムを実装する。
/
- 領域: TypeScript コア・型システムの基礎
- テーマ: Template Literal Typesを用いた引数の動的バリデーション
/
// 1. 許可するドメインとアクションの定義(Union Types)
type AllowedDomain = ‘user’ | ‘order’ | ‘payment’;
type AllowedAction = ‘create’ | ‘update’ | ‘delete’ | ‘fetch’;
// 2. テンプレートリテラル型による厳密な文字列フォーマットの定義
// 期待するフォーマット: `action:${AllowedDomain}:${AllowedAction}`
type ValidActionKey = `action:${AllowedDomain}:${AllowedAction}`;
/
- 3. 型ガード用のヘルパー型(無効な文字列が渡された際に、型レベルで詳細なエラーメッセージを浮き上がらせる高度なテクニック)
/
type ValidateActionKey
? T
: never; // もしマッチしない場合は never に落とし込み、コンパイルエラーを誘発する
/
- 4. メインの関数実装
- 引数の型制約により、不正な文字列はコンパイル時に完全に排除される。
/
function dispatchDomainAction
key: ValidateActionKey
payload: Record
): void {
// 実行時にはすでに型安全が保証されているため、無駄な `if` による文字列チェックは不要
console.log(`[Executing Action]: ${key}`, payload);
const [prefix, domain, action] = key.split(‘:’);
// ドメインに応じた処理…
}
// ==========================================
// 使用例(IDEの補完とコンパイル結果)
// ==========================================
// ✅ 成功例:完全に正しいフォーマット。IDEのサジェストもフルに効く。
dispatchDomainAction(‘action:user:create’, { userId: ‘123’ });
dispatchDomainAction(‘action:payment:update’, { amount: 1000 });
// ❌ コンパイルエラー例:プレフィックスが違う
// Type ‘”user:create”‘ is not assignable to type ‘never’.
// dispatchDomainAction(‘user:create’, {});
// ❌ コンパイルエラー例:存在しないドメインを指定している
// Type ‘”action:product:create”‘ is not assignable to type ‘never’.
// dispatchDomainAction(‘action:product:create’, {});
// ❌ コンパイルエラー例:タイポ(create が crrt になっている)
// Type ‘”action:user:crrt”‘ is not assignable to type ‘never’.
// dispatchDomainAction(‘action:user:crrt’, {});
—
この設計が優れている理由:コードレビューの視点から
テクニカルリードとして、このコードがなぜ優れているのか、アーキテクチャの観点から解説しよう。
1. ジェネリクスとConditional Typesの融合
単に `key: ValidActionKey` と定義するだけでも、不正な文字列の代入は防げる。しかし、あえてジェネリクス `T extends string` と Conditional Types (`T extends ValidActionKey ? T : never`) を組み合わせているのには理由がある。
これにより、呼び出し側でリテラル型が保持され、IDEのエラーメッセージに「なぜそれがダメなのか」のコンテキストを正確に伝えることができる。TypeScriptコンパイラは、`never` を検知した瞬間に厳格な型ミスマッチエラーを吐き出す。
2. ランタイムコストの完全な排除
バリデーションロジックをランタイム(実行時)に持たせない。JavaScriptにトランスパイルされた後、余計な文字列パースや正規表現の実行、例外スローのコストがバンドルサイズから削ぎ落とされる。パフォーマンスが命であるフロントエンドの基盤コードや、高スループットなNode.jsのバックエンドにおいて、これは極めて重要な最適化だ。
—
発展:動的に構築される文字列を扱う場合の注意点
ここで、TypeScriptの型システムに関する非常に重要な注意点を共有しておく。
もし、引数がリテラル(ハードコードされた文字列)ではなく、外部から動的に入ってきた `string` 型(例: `const input: string = getUserInput();`)である場合、TypeScriptはそれを `string` 型としか認識しないため、上記の `dispatchDomainAction(input, …)` はたとえ実行時に正しい値が入っていたとしてもコンパイルエラーになる。
const userInput: string = ‘action:user:create’;
// ❌ コンパイルエラー: Argument of type ‘string’ is not assignable to parameter of type ‘never’.
// dispatchDomainAction(userInput, {});
これはTypeScriptの仕様(WideningとType Narrowingの境界)によるものだ。動的な文字列をどうしてもこの関数に渡したい場合は、User-Defined Type Guards(ユーザー定義型ガード)を定義し、型述語(Type Predicate)を用いてコンパイラに「この関数を通ったということは、この文字列は `ValidActionKey` である」と証明する必要がある。
// 実行時にも検証が必要な動的入力のための型ガード関数
function isValidActionKey(value: string): value is ValidActionKey {
const parts = value.split(‘:’);
if (parts.length !== 3) return false;
const [prefix, domain, action] = parts;
return (
prefix === ‘action’ &&
[‘user’, ‘order’, ‘payment’].includes(domain) &&
[‘create’, ‘update’, ‘delete’, ‘fetch’].includes(action)
);
}
// 使い方
const dynamicInput = ‘action:user:create’; // 実際にはAPIや外部から取得した文字列
if (isValidActionKey(dynamicInput)) {
// このブロック内では、dynamicInput は `ValidActionKey` に絞り込まれる(Type Narrowing)
dispatchDomainAction(dynamicInput, { safe: true });
} else {
console.error(‘Invalid dynamic action key’);
}
—
結びに代えて
型システムとは単なる「エラー発見ツール」ではない。「間違ったコードを書くことを物理的に不可能にし、正しいコードを書くことだけを極限まで楽にするインフラストラクチャ」である。
Template Literal Typesを関数引数のバリデーションに応用することで、あなたの書くコードベースから「文字列のタイポによるバグ」を根絶やしにできる。
明日からのコードレビューでは、ランタイムの `if` 文で文字列をチェックしているコードを見つけたら、こう問いかけてほしい。
――「それ、型で縛れますよね?」と。