プログラミング
パースせよ、検証するな – あなたが望まない言語で
Parse, Don't Validate – In a Language That Doesn't Want You To (cekrem.github.io)
要約
この記事では、TypeScriptのような言語において「パースせよ、検証するな」という原則をどのように適用するかについて考察しています。通常のバリデーションでは型情報が失われるのに対し、パーシングではより厳密な型を導入することで、プログラム全体での安全性を高めることができます。TypeScriptの構造的型付けの特性を克服するために、ブランド型を使った具体的な実装例が示されています。
全文翻訳
更新:もしこの記事を気に入っていただけたなら、続編である「Effect Without Effect-TS: Algebraic Thinking in Plain TypeScript」で、ここでの内容をさらに深掘りしています。
アレクシス・キングの「Parse, don’t validate」について、またしても考えていました。私はかなり定期的にそうしています。通常は、フジツボのように静かに if (user.email) チェックを蓄積しているTypeScriptのコードベースをじっと見つめた後です。この投稿は2019年のものであり、このアドバイス(あるいは原則)はそれよりもずっと古いものです。それでも、私が読むTypeScriptのほとんど — 恥ずかしながら、私が書いたものもたくさん含めて — は、パースする代わりにまだ検証しています。
読んでいない方のために(読むべきですが)、その内容はこうです:バリデータは「これは問題ない、続行してください」と言います。パーサーは「ブロブをください。そして、より正確な型を返すか、なぜできないかを教えます」と言います。この違いは、バリデータが実行を終えた瞬間に情報を捨て去るのに対し、パーサーが学んだことを型にエンコードすることで保存するという事実に気づくまで、学術的なものに聞こえます。一度文字列をEmailAddressにパースすれば、プログラムの残りの部分は二度と疑問に思う必要がありません。心の平和と、楽しいことのためにより多くの精神的キャパシティが得られます。
Haskell、Elm、F#では、これはまさにコードの書き方です。言語があなたをその方向へ導きます。TypeScriptでは…そうではありません。TypeScriptは喜んで正しいことをさせてくれますが、主張はせず、優しく促すこともしません。どちらかといえば、構造的型付けが積極的に全体のゲームを損なっています。
私が何を言っているのかお見せしましょう。
私たちが皆書いたバリデータ
ここに、私が絶えず見かける(そして書く)種類のコードがあります:
```typescript
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;
}
function sendWelcome(user: User) {
if (!isValidUser(user)) {
throw new Error("invalid user");
}
// ...後で、コールスタックの深いところで:
emailService.send(user.email, `Welcome, age ${user.age}`);
}
```
嘘を見つけられますか?User.emailは単なるstringです。User.ageは単なるnumberです。検証は行われました — おめでとうございます — しかし、isValidUserが戻った瞬間に型システムはそのことを忘れてしまいました。3つの関数呼び出しが深くなったところで、誰かがuser.emailに触れるとき、それが本当のメールアドレスを期待する関数に渡されるのを止めるものはありません。なぜなら、TypeScriptが知る限り、それは単なるstringだからです。""と同じ、"hello"と同じ、"definitely not an email"と同じです。
ではどうすればいいのでしょうか?再検証します。別のifを追加します。単体テストを書きます。希望を抱きます。(キングは元の投稿でこれについて「ショットガンパース」というはるかに良い言葉を使っています — 検証があらゆるところに散らばっていて、どれも記憶されていない状態です。)
私たちが本当に望むもの
私たちが望むのはこれです:
```typescript
function sendWelcome(user: ValidUser) {
emailService.send(user.email, `Welcome, age ${user.age}`);
}
```
そして、パーサーを通っていないものは何であってもsendWelcomeを呼び出すことが不可能であることを望みます。再チェックや「防御的プログラミング」は不要です。型自体が、言ってみれば、その証拠となるのです。
Elmであれば、不透明な型とスマートコンストラクタで、およそ4行で済むでしょう。TypeScriptでは、まあ、少なくとも可能です。ただ、あまり快適ではありません。
ブランド型、あるいは意図的に構造的型システムに嘘をつくこと
TypeScriptは構造的型付けであり、同じ形状の2つの型は同じ型であることを意味します。stringはstringでありstringです。newtypeはありません。Haskellがそうするように、真に異なる型を生成する型 EmailAddress = String はありません。
コミュニティが採用した回避策はブランディングです — タギング、あるいは交差型による名目型付けとも呼ばれます。安価なバージョンは文字列リテラルファントム ({ readonly __brand: "Email" }) であり、それはいたるところで見られるでしょう。少しだけ安価ではないバージョンは、モジュールからエクスポートしないユニークなシンボルを使用します。これにより、外部の誰もそのブランドを偽造するために名前を綴ることすらできなくなります:
```typescript
declare const EmailBrand: unique symbol;
declare const AgeBrand: unique symbol;
type Email = string & { readonly [EmailBrand]: true };
type Age = number & { readonly [AgeBrand]: true };
```
実行時にはブランドフィールドは存在しません。それは「ファントム」です — コンパイル時にEmailとstringを互換性のないものにする型レベルのマーカーです。Emailを取得する唯一の方法は、その方法を知っている関数を介することです。なぜなら、このモジュールの外では誰もそのシンボルを偽造するために名前を付けることすらできないからです。(TS5ではテンプレートリテラル型 — type Email = `${string}@${string}` — を試すこともできます。これはデモには楽しいですが、それだけでは不十分です。)これは、言語から離れることなく不正な状態を表現不可能にする動きです。
ちなみに、ブランドは一方向です:Emailは依然としてstringに代入可能です。ドメインに入るときは名目型、出るときは構造型、これはまさにあなたが望むものです。
その関数があなたのパーサーです:
```typescript
type ParseError = { kind: "ParseError"; message: string };
type Parsed<T> = { kind: "ok"; value: T } | { kind: "err"; error: ParseError };
function parseEmail(raw: string): Parsed<Email> {
if (!raw.includes("@")) {
return { kind: "err", error: { kind: "ParseError", message: "missing @" } };
}
// チェックしたので、意図的に型システムに嘘をつきます
return { kind: "ok", value: raw as Email };
}
function parseAge(raw: unknown): Parsed<Age> {
if (
typeof raw !== "number" ||
!Number.isInteger(raw) ||
raw < 0 ||
raw > 150
) {
return { kind: "err", error: { kind: "ParseError", message: "bad age" } };
}
return { kind: "ok", value: raw as Age };
}
```
(parseEmailの述語は恥ずかしいほど薄っぺらいです — 実際のものはトリミング、小文字化、そして少なくともドメイン部分の検証を装うでしょう。しかし、私はブログ記事でメールパーサーを書いているわけではありません!) `as Email` は少し痛みを伴い、そうあるべきです。これは、ルールを破ることが許される唯一の場所です — パーサーは信頼できる境界です。コードベースの他の場所では、文字列からEmailを呼び出すことはできません。`parseEmail` を呼び出し、両方の分岐を処理する必要があります。(私は意図的に `kind: "ok" | "err"` を使用しており、ブーリアンの識別子ではありません。ブーリアンはきれいに見えますが、誰かが3つ目のケースを追加すると網羅性が静かに機能しなくなります。文字列は正直に絞り込まれます。)
私たちが始めた、例外を投げて祈るバリデータと比較してください:その失敗モードは例外であり、型システムには見えません。パーサーのシグネチャは、起こりうるすべてのことを教えてくれます。コールスタックに隠された3番目の選択肢はありません。
次にドメイン型です。通常混同される2つのこと、つまり、ワイヤーから送られてきた生のブロブと、信頼する権利を得たものを区別して名前を付けたいと思います。
```typescript
declare const UserIdBrand: unique symbol;
type UserId = number & { readonly [UserIdBrand]: true };
type UnvalidatedUser = {
id: unknown;
email: unknown;
age: unknown;
};
type ValidUser = {
readonly id: UserId;
readonly email: Email;
readonly age: Age;
};
function parseUserId(raw: unknown): Parsed<UserId> {
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
return { kind: "err", error: { kind: "ParseError", message: "bad id" } };
}
return { kind: "ok", value: raw as UserId };
}
function parseUser(raw: unknown): Parsed<ValidUser> {
if (typeof raw !== "object" || raw === null) {
return { kind: "err", error: { kind: "ParseError", message: "not an object" },
};
}
if (!("id" in raw) || !("email" in raw) || !("age" in raw)) {
return { kind: "err", error: { kind: "ParseError", message: "missing fields" },
};
}
if (typeof raw.email !== "string") {
return { kind: "err", error: { kind: "ParseError", message: "email not a string" },
};
}
const id = parseUserId(raw.id);
if (id.kind === "err") return id;
const email = parseEmail(raw.email);
if (email.kind === "err") return email;
const age = parseAge(raw.age);
if (age.kind === "err") return age;
return { kind: "ok",
value: { id: id.value, email: email.value, age: age.value },
};
}
```
`UnvalidatedUser` を `ValidUser` とは別に命名することは、それ自体で価値のある小さなDDDの動きです:生データが入り、信頼されたデータが出てきて、その境界は関数です。`id` もブランド化されています — これまで