検証するな、パースせよ — TypeScriptのような望ましくない言語でも
(cekrem.github.io)- TypeScriptコードに
if (user.email)のような確認が散らばると、すでに確認した事実が型に残らないため、呼び出しスタックの後段で同じ条件を繰り返し疑うことになる - パーサーは生の入力を受け取り、より狭い型または失敗情報を返すことで、
EmailAddressのような検証済みの事実をプログラムの残りの部分が信頼できるようにする - 構造的型システムを使うTypeScriptでは、
stringとEmailは自然には分離されないため、unique symbolベースのブランド型と限定的なasアサーションで名目的な境界を擬似的に作る Parsed<T>のような判別共用体は成功と失敗を型シグネチャに表せるが、専用のmatch式がないため、neverを使った exhaustive check を自分で書く必要がある- Zod、io-ts、valibot はスキーマからパーサーとTypeScript型を一緒に作れるが、外部入力をドメイン型として扱う前に境界ごとにパースするという規律は依然として開発者に委ねられている
検証は情報を捨て、パースは型に残す
- Alexis KingのParse, don’t validate 原則は、バリデーターとパーサーの違いを中心に据える
- バリデーターは「この値は問題ない」と判断したあと、boolean や例外で処理を先へ進める
- パーサーは生の入力を受け取り、より精密な型を作るか、失敗理由を返す
User.email: string,User.age: numberのように型が広いままだと、isValidUser(user): booleanが通ってもTypeScriptはその事実を記憶できない- その後の
emailService.send(user.email, ...)のようなコードでも、user.emailは依然として空文字列、"hello"、"definitely not an email"のようなただの stringにすぎない - 同じ条件を複数箇所で再確認する流れは、Kingのいうshotgun parsingに近い
型そのものが証拠になるAPI
- 理想の形は
sendWelcome(user: ValidUser)のように、パース済みの値だけを受け取れる関数シグネチャである - この構造では、
sendWelcomeを呼ぶ前に必ずパーサーを通過しなければならず、関数内部で再検証や防御的なifは不要になる - Elmでは opaque type と smart constructor で簡単に実現できるが、TypeScriptで同じ効果を出すにはより多くの仕掛けが必要になる
ブランド型で名目的な境界を作る
- TypeScriptは構造的型システムを使うため、同じ shape を持つ型は同じ型として扱われる
stringはstringであり、Haskellのnewtypeのように本当に別の型を作る機能はない
- コミュニティで使われる回避策が**ブランディング(branding)**またはタグ付けである
- 単純な方法は
{ readonly __brand: "Email" }のような文字列リテラルの phantom field - より強い方法は、モジュール外に公開しない
unique symbolをブランドキーに使うこと
- 単純な方法は
- 例の型は
type Email = string & { readonly [EmailBrand]: true },type Age = number & { readonly [AgeBrand]: true }の形になる - ブランドフィールドはランタイムには存在しない型レベルのマーカーであり、
Emailとstringをコンパイル時に別物として扱えるようにする - ブランドは一方向にしか働かない
Emailはstringに代入できる- 普通の
stringはそのままEmailには入れられない
パーサーは信頼境界でのみアサーションを許す
parseEmail(raw: string): Parsed<Email>は、文字列に@がなければ失敗を返し、通過すればraw as Emailでブランド型を作るas Emailアサーションは、パーサーが信頼境界だからこそ許される例外である- コードベースの他の場所で
stringをEmailにアサートすると設計が崩れる - パーサーを別モジュールに置き、そこで以外にブランドのアサーションが現れたらバグとして扱える
- コードベースの他の場所で
- 例の
Parsed<T>は{ kind: "ok"; value: T } | { kind: "err"; error: ParseError }の形である- 失敗は例外の中に隠れず、型シグネチャに現れる
kind: "ok" | "err"のような文字列ディスクリミネータを使うと、後からバリアントが追加されたときの型の絞り込みもより素直に働く
parseEmailの例は意図的に薄く、実際のメールアドレスパーサーでは trim、lowercase、ドメイン検証などをさらに扱う必要がある
生入力と信頼済みドメイン型を分離する
UnvalidatedUserとValidUserを分けると、ネットワークや外部入力から来た値と、ドメイン内で信頼できる値を明確に区別できるUnvalidatedUserではid,email,ageをunknownにしておくValidUserではUserId,Email,Ageのようなブランド型を使う
UserIdもブランド化しておけば、UserIdが必要な場所にOrderIdのような別のIDを誤って渡すミスを防げるparseUser(raw: unknown): Parsed<ValidUser>は生入力を段階的に絞り込む- 入力がオブジェクトかを確認する
id,email,ageフィールドの存在を確認するemailが文字列かを確認するparseUserId,parseEmail,parseAgeをそれぞれ呼び、失敗したら即座に返す- すべて成功したら
ValidUserを返す
- この方法はF#やElmより冗長だが、
sendWelcome(user: ValidUser)は本当に安全になる
TypeScriptで引っかかる点
- 1つ目の摩擦は、パーサー内部の
as Emailアサーションである- 本物の名目的型言語なら、smart constructor は嘘をつかずに新しい型を返せる
- TypeScriptのブランドは仮想的な型マーカーなので、パーサー側でアサーションを越える必要がある
- 2つ目の摩擦は exhaustive check である
- TypeScriptの判別共用体はこのスタイルで強力だが、専用の
match式はない switchのdefaultでconst _exhaustive: never = resultのようなパターンを自分で書かなければならないParsedに3つ目のバリアントが追加されると、neverへの代入が失敗してコンパイラが場所を教えてくれる
- TypeScriptの判別共用体はこのスタイルで強力だが、専用の
satisfiesはキャストより穏当な escape hatch として使えるconst x = { ... } satisfies Configは型を検査しつつ、リテラル型を不必要に広げない
JSON.parseはanyを返すため、すぐにunknownとして注釈するほうが安全であるconst raw: unknown = JSON.parse(input)の形で受け取り、その後パーサーがドメイン型かどうかを判断するJSON.parseはバリデーターではなく、バイト列をJS値へ変換するデシリアライズ段階である
Zodのようなライブラリが減らす繰り返し
- Zod、io-ts、valibot は手書きのパーサーより手軽な形で同じパターンを提供する
- Zodの例では、1つのスキーマからパーサーとTypeScript型を一緒に作る
z.object({ id: z.number().int(), email: z.string().email().brand<"Email">(), age: z.number().int().min(0).max(150).brand<"Age">() })z.infer<typeof ValidUserSchema>で型を得るValidUserSchema.safeParse(rawInput)は成功時に data、失敗時に error を返す
- Zodの
.brand()も手作りの symbol ブランドと同じく型レベルの機能であり、ランタイムの動作はない - ライブラリはパーサーと型を同じ定義に結びつけて境界を守りやすくするが、すべての外部境界でそれを使うべきだという規律まで強制してくれるわけではない
- ネットワークから来た
Userは、パースされるまではドメインのUserではなく、型アサーションでエラーメッセージを回避したくなる誘惑を避ける必要がある
証拠を記憶ではなく型に載せる
- 小さな原則は「型システムに証拠を持たせ、人の記憶に任せるな」ということだ
- ある条件を確認しても、その結果を型にエンコードしなければ、その後のコードはその検証がもう終わっていると簡単に思い込んでしまう
- TypeScriptでは、この原則は3つの道具に支えられて実装される
- 名目的な同一性を擬似的に作るブランド型
- 成功と失敗を表に出す判別共用体
- 外部入力の
unknownと信頼済みドメイン型のあいだに引く厳格な境界
- すべてのコードをパースパイプラインに変えるのが常に適切とは限らないが、同じ防御的な
ifが複数ファイルに繰り返し現れるなら、それは検証すべき情報を型に載せられていないサインである
1件のコメント
Lobste.rs のコメント
JavaScript/TypeScript が望むコードスタイルと、技術的・人間工学的に衝突するなら、数多くある JS にコンパイルされる言語 のどれかを使えばいいのでは、と思う
Haskell、Elm、F# が挙げられているし、PureScript、js_of_ocaml、Reason、LunarML など、筆者がより使いたがりそうな系統の言語も多い。筆者は Why TypeScript Won’t Save You という記事まで書いて好みの言語群とさらに比較しており、https://learnelm.dev も運営している。
あるいは比較そのものが目的で、TypeScript が多くの場合十分ではないことを示し、別のツールチェーンやアイデアの採用を促そうとしているのかもしれない
ほとんどの場合、単に別の言語を選ぶ選択肢や時間がない
仕事では ブランド型(branded type) がとても気に入っているが、ブランド化された数値だけでインデックスできる Array や TypedArray を作れない点が本当に気になる
TypedArray はブランド化された数値を格納することも、より正確には取り出して読むことすらできない。IndexArray や IndexTypedArray のような別の型セットが必要になるとしても、こういう機能はぜひ欲しい
かなり複雑なデータベーススキーマで全 ID にブランド型を使うと、筋の通らない join や条件を作ったときに TypeScript が検出してくれる。関数シグネチャもより明確になり、いろいろなミスを起こしにくくなる
望むなら、TypedArray の値にも同じやり方で可能
TArray<Foo, MyEnum>のように書ける。ただしこれは C++ の話Zig の
stdライブラリには、comptimeで実装された EnumArray がある。密な enum や疎な enum をインデックスに使い、コンパイル時に正しいインデクサを計算するなど、より幅広い機能も提供している。こうした 精密な型付け がますます気に入っている。コードベースにロジックバグが入り込むこと自体をかなり防いでくれる