1 ポイント 投稿者 GN⁺ 4 시간 전 | 1件のコメント | WhatsAppで共有
  • TypeScriptコードに if (user.email) のような確認が散らばると、すでに確認した事実が型に残らないため、呼び出しスタックの後段で同じ条件を繰り返し疑うことになる
  • パーサーは生の入力を受け取り、より狭い型または失敗情報を返すことで、EmailAddress のような検証済みの事実をプログラムの残りの部分が信頼できるようにする
  • 構造的型システムを使うTypeScriptでは、stringEmail は自然には分離されないため、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 を持つ型は同じ型として扱われる
    • stringstring であり、Haskellの newtype のように本当に別の型を作る機能はない
  • コミュニティで使われる回避策が**ブランディング(branding)**またはタグ付けである
    • 単純な方法は { readonly __brand: "Email" } のような文字列リテラルの phantom field
    • より強い方法は、モジュール外に公開しない unique symbol をブランドキーに使うこと
  • 例の型は type Email = string & { readonly [EmailBrand]: true }, type Age = number & { readonly [AgeBrand]: true } の形になる
  • ブランドフィールドはランタイムには存在しない型レベルのマーカーであり、Emailstring をコンパイル時に別物として扱えるようにする
  • ブランドは一方向にしか働かない
    • Emailstring に代入できる
    • 普通の string はそのまま Email には入れられない

パーサーは信頼境界でのみアサーションを許す

  • parseEmail(raw: string): Parsed<Email> は、文字列に @ がなければ失敗を返し、通過すれば raw as Email でブランド型を作る
  • as Email アサーションは、パーサーが信頼境界だからこそ許される例外である
    • コードベースの他の場所で stringEmail にアサートすると設計が崩れる
    • パーサーを別モジュールに置き、そこで以外にブランドのアサーションが現れたらバグとして扱える
  • 例の Parsed<T>{ kind: "ok"; value: T } | { kind: "err"; error: ParseError } の形である
    • 失敗は例外の中に隠れず、型シグネチャに現れる
    • kind: "ok" | "err" のような文字列ディスクリミネータを使うと、後からバリアントが追加されたときの型の絞り込みもより素直に働く
  • parseEmail の例は意図的に薄く、実際のメールアドレスパーサーでは trim、lowercase、ドメイン検証などをさらに扱う必要がある

生入力と信頼済みドメイン型を分離する

  • UnvalidatedUserValidUser を分けると、ネットワークや外部入力から来た値と、ドメイン内で信頼できる値を明確に区別できる
    • UnvalidatedUser では id, email, ageunknown にしておく
    • 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 式はない
    • switchdefaultconst _exhaustive: never = result のようなパターンを自分で書かなければならない
    • Parsed に3つ目のバリアントが追加されると、never への代入が失敗してコンパイラが場所を教えてくれる
  • satisfies はキャストより穏当な escape hatch として使える
    • const x = { ... } satisfies Config は型を検査しつつ、リテラル型を不必要に広げない
  • JSON.parseany を返すため、すぐに 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件のコメント

 
GN⁺ 4 시간 전
Lobste.rs のコメント
  • JavaScript/TypeScript が望むコードスタイルと、技術的・人間工学的に衝突するなら、数多くある JS にコンパイルされる言語 のどれかを使えばいいのでは、と思う
    Haskell、Elm、F# が挙げられているし、PureScript、js_of_ocaml、Reason、LunarML など、筆者がより使いたがりそうな系統の言語も多い。筆者は Why TypeScript Won’t Save You という記事まで書いて好みの言語群とさらに比較しており、https://learnelm.dev も運営している。
    あるいは比較そのものが目的で、TypeScript が多くの場合十分ではないことを示し、別のツールチェーンやアイデアの採用を促そうとしているのかもしれない

    • 既存のコードベース、チームの特定言語への習熟度や会社の方針、サポート・ツール・コミュニティ規模の小ささといった制約がある
      ほとんどの場合、単に別の言語を選ぶ選択肢や時間がない
    • たいていは大きな TypeScript コードベース があるか、ほかの言語にはない TypeScript ライブラリを使っているからだと思う
  • 仕事では ブランド型(branded type) がとても気に入っているが、ブランド化された数値だけでインデックスできる Array や TypedArray を作れない点が本当に気になる
    TypedArray はブランド化された数値を格納することも、より正確には取り出して読むことすらできない。IndexArray や IndexTypedArray のような別の型セットが必要になるとしても、こういう機能はぜひ欲しい

    • 私もブランド型は好きだが、話してみると、みんな労力の割にはいまいちだと見ている
      かなり複雑なデータベーススキーマで全 ID にブランド型を使うと、筋の通らない join や条件を作ったときに TypeScript が検出してくれる。関数シグネチャもより明確になり、いろいろなミスを起こしにくくなる
    • 十分に強く嘘をつく気があるなら、ブランド化された数値だけでインデックス可能な Array を作ることはできる
      望むなら、TypedArray の値にも同じやり方で可能
    • 職場では「スマート enum」とカスタム配列型を使って、TArray<Foo, MyEnum> のように書ける。ただしこれは C++ の話
      Zig の std ライブラリには、comptime で実装された EnumArray がある。密な enum や疎な enum をインデックスに使い、コンパイル時に正しいインデクサを計算するなど、より幅広い機能も提供している。
      こうした 精密な型付け がますます気に入っている。コードベースにロジックバグが入り込むこと自体をかなり防いでくれる