「検証」では情報が失われる—TypeScript の構造型の落とし穴
多くの TypeScript コードベースで見かけるパターンがあります。ユーザーオブジェクトに対して isValidUser() という関数で検証を行い、条件分岐で合否を判定するというものです。
interface User {
id: number;
email: string;
age: number;
}
function isValidUser(user: User): boolean {
if (!user.email.includes("@")) return false;
if (user.age < 0 || user.age > 150) return false;
return true;
}
ここで重大な問題が隠れています。関数実行後、TypeScript の型システムは検証が行われたという情報を一切保持していません。user.email は相変わらず単なる string のままです。
検証で確認した条件は型に刻み込まれず、3階層深い関数呼び出しの先でも、誰でも任意に値を操作できてしまいます。
これを「ショットガン検証」と呼ぶ人もいます(Parse, don't validate)。検証ロジックが各所に散らばり、その結果が記憶されない状態です。単体テストで防ぐしかなく、心理的な負担が増します。
「解析」とは何か—型が証明になる設計
では、どうするべきか。目指すべき形は以下の通りです:
function sendWelcome(user: ValidUser) {
emailService.send(user.email, `Welcome, age ${user.age}`);
}
関数のシグネチャが 証明 として機能しています。ValidUser という型を受け取ることで、「この値は既に検証済みである」ことが保証されます。
呼び出し側は、ValidUser を直接作れません。パーサー関数を通すしかない。
Elm や Haskell、F# ではこれが言語の標準的な書き方です。Haskell の opaque type(不透明型)なら 4 行で実装できます。ところが TypeScript は違います。
ブランド型—構造型への「嘘」をついて名目型を作る
TypeScript は 構造型 言語です。同じ形のオブジェクトは、定義がどこであれ同じ型と見なされます。つまり Haskell のような type EmailAddress = String で別の型を作ることはできません。
そこで TypeScript コミュニティが編み出したのが ブランド型(タグ型、名目型による交差型)です。
type ValidEmail = string & { readonly __brand: "Email" };
type ValidAge = number & { readonly __brand: "Age" };
type ValidUser = {
id: number;
email: ValidEmail;
age: ValidAge;
};
function parseEmail(email: string): ValidEmail | null {
if (email.includes("@")) {
return email as ValidEmail;
}
return null;
}
function parseUser(user: User): ValidUser | null {
const email = parseEmail(user.email);
if (!email || user.age < 0 || user.age > 150) {
return null;
}
return {
id: user.id,
email,
age: user.age as ValidAge,
};
}
このパターンで重要なのは、パーサー関数の戻り値の型が検証結果を表現する という点です。成功時は ValidUser、失敗時は null。型システムが結果を記憶します。
自分のスクリプトに組み込む場合、ブランド型は Result 型(成功/失敗を区別する型)と組み合わせるとさらに強力になりますし、Claude Code に投げてみたら案外すぐ完成形になりそうだという印象を持っています。
| 特性 | 従来の検証(isValidUser) | ブランド型での解析(parseUser) |
|---|---|---|
| 結果の記憶 | ✗(型に反映されない) | ✓(型システムが保証する) |
| 再検証の必要性 | ✓(どこでも必須) | ✗(型があれば十分) |
| 呼び出し側の自由度 | 高い(未検証値も受け取れる) | 低い(パーサーを通さないと受け取れない) |
| TypeScript での実装難度 | 低い | 中程度(ブランド型の理解が必要) |
より堅牢なブランド型—シンボルを使った偽造防止
シンプルな { readonly __brand: "Email" } は、モジュール外から as キャストで偽造される恐れがあります。より堅牢にするには、エクスポートしない unique symbol を使います:
declare const EmailBrand: unique symbol;
type ValidEmail = string & { [EmailBrand]: never };
// モジュール内部のパーサーのみが ValidEmail を生成可能
function createValidEmail(email: string): ValidEmail | Error {
if (!email.includes("@")) {
return new Error("Invalid email");
}
return email as ValidEmail;
}
このパターンなら、モジュール外から正当なパスを通さずに ValidEmail を作ることは不可能です。型の不変性がコード上で強制されます。
現実的な導入—段階的な移行とドキュメント化
ブランド型を既存プロジェクトに導入する場合、いくつかの実践的なポイントがあります。
- 段階的な移行:すべての型を一度に変更するのではなく、重要な入出力(API レスポンス、ユーザー入力)から始める
- パーサーの集中管理:バリデーションロジックを一箇所に集め、モジュールの export として公開する
- Result 型の活用:単なる
null返却ではなくResult<ValidUser, ValidationError>のような型で、エラー理由を渡す - ドキュメント:開発者に「なぜこの型があるのか」を明確に説明する。さもないと、
asキャストで無視される可能性が高い
TypeScript は言語として検証から解析への転換を「勧めない」ので、チーム内でこの設計パターンを採用する意思決定と教育が不可欠です。
Python や TypeScript を日常的に扱う開発者の方なら、このパターンの価値がすぐに分かると思います。再検証の煩雑さから解放されることのメリットは、コードを書く段階で即座に実感できるはずです。
他言語との比較—なぜ TypeScript は「勧めない」のか
この違いは言語設計の根本にあります。
- Elm・Haskell・F#:型システムが検証後の値を自動的に区別。言語がパターンを強制する
- TypeScript:構造型により、同じ形のオブジェクトはすべて等価。言語は許可するが勧めない
- Python:型ヒント自体が optional。実行時に強制されない
TypeScript が「勧めない」理由は、既存コードとの互換性を保つ必要があるからです。すべての string を区別する型にすれば、既存の関数群が全て壊れます。
だからこそ、ブランド型は「opt-in な改善」としての価値があります。新規モジュールから採用し、チーム内で運用ルールを定めることで、徐々に信頼性の高い設計へ移行できます。