👮‍♂️

TypeScript × Zodで、安全なI/Oゲートを作る

に公開

経緯

元々Zodは使っていたのだけど、そもそも検証自体を仕組みとして組み込むほうが自然だよねと思っている。
自分が作るプロジェクトでは、大まかにこんな感じで導入してるよ!っていうサンプル記事です。

散々擦り倒されてるネタだとは思いつつ、やっぱりこの手の構造を考えるのは楽しいんですよねw

情報を扱う際に、気をつけるべき点として

「システム内で扱うデータは、把握している範囲で」

というのが非常に重要だと思っており、今回のGate構造の出発点です。

たまに、「あ、このデータも来てたわー」みたいに助かるケースがありますが、改めて考えると、どう考えても事故にしか見えないですw
可能であれば「把握しているデータしか流れていない」状態を作れるように努力したいところです。

たとえば、fetchまわりに仕込んであげると、
送信するデータに余計なデータが乗らない + 戻ってきたデータに余計なデータが含まれていない
という状態がつくれるので、結構お薦めだったりします。

自分が作ったものでは、React Next の WebAPI呼び出し と API Handler の両方に
コレのカスタム版の仕組みを導入してます。

ちなみに、自作ライブラリ r-pipeline に対して、そのまま組み込めるようなgate処理として考えているので、付属ライブラリ化も視野に入れつつ。

仕組みの話

Zodの大まかなところは、いっぱい書いている人がいるのでおまかせするとして、
Zodは「スキーマ定義と実行時検証ができる」というのがポイントだと考えています。

とはいえ、すべての箇所に検証を手書きする場合、ヌケモレや場所による差異など、人間であればさけられない問題がでてきてしまうとも思っています。
であれば、統一した構造で必ずチェックしてあげればいいかと。

といった感じで、ここではZodそのものの解説よりも、「どう設計に組み込むか」という話にフォーカスします。

全体像

Zodで定義したスキーマを通過口(Gate)として扱い、
「入力 → 処理 → 出力」のすべてでフォーマットを検証します。

外部データ → [Gate(inSchema)] → Handler → [Gate(outSchema)] → 内部システム

って感じの仕組みで考えています。

コード

メインはこっち
https://github.com/risk/ts-playground/blob/main/src/gatePrototype/gatePrototype.ts

async版も書いたので、興味があればこちらもどうぞ

フォーマットエラーの共通定義

https://github.com/risk/ts-playground/blob/main/src/gatePrototype/asyncGatePrototype.ts
内部で発生したエラーは、エラー型を拡張してあげて扱うほうが、instanceofとかがつかいやすくなるかなと思っているので、今回のこの仕組みようにErrorを拡張しています。

Gate本体

https://github.com/risk/ts-playground/blob/5b2b08a12e1399962c903774ef0ff1b02542df98/src/gatePrototype/gatePrototype.ts#L29-L59
こいつが、ハンドラ定義の本体になってます。
「スキーマを設定 → 処理を注入」 という2段構造(カリー化)にしています。
これは、同一のSchemaのハンドラを複数作るケースもあり得るとは思っていて、その時に「同じSchemaを何度も記述した場合、間違えちゃう可能性」があるので、Schemaは共通で中の処理だけが違うという実装を実現できるようにしています。
こうすることで、「型定義」と「処理」を分離しつつ、一貫した安全性を保てるようになっています。

大まかな流れとして

  1. Schemaを指定して Gateの作成メソッドを作る
  2. 作成メソッドに、処理を指定してハンドラを作る
  3. そのハンドラに入力値を通せば、ハンドラでは確実にその型で情報を扱える
  4. ハンドラが値を返すときに、指定フォーマット通りになっていることを保証する

こんな感じの流れにしています。

実際にどんな感じでつかえるかは、サンプルコードに記載

https://github.com/risk/ts-playground/blob/5b2b08a12e1399962c903774ef0ff1b02542df98/src/gatePrototype/gatePrototype.ts#L61-L137

色々Schemaを書いて、ゲートを通していますので見ていただいたり、実行していただくとわかりやすいかと思います。

ポイントとして、Object(z.object)を指定する場合は、strict をつけるようにしていますが、コレには理由がありまして・・・

使いみちとしてWebAPIやDatabaseなどの、動的に値が確定するデータを、システム内に持ち込む場合に、余計なデータの入力や、余計なデータの出力は、大きな情報流出の事故につながるケースが少なくありません。

例えば、UserデータをWebAPIで受信する仕組みがあり、自分はそこに個人情報がないと思っていたが、実は個人情報が入り込んでいて、console.logとかで出力して丸見えとか・・・
実際、TypeScriptでは、型レベルでランタイムデータの検証を行うことが出来ないので、Zod等で守る必要がどうしてもでてきます。

このときに便利なのが strict で、その形であることを保証してくれます(情報が多い場合もエラーにします)
https://github.com/risk/ts-playground/blob/5b2b08a12e1399962c903774ef0ff1b02542df98/src/gatePrototype/gatePrototype.ts#L116
この辺で試してますね。

この仕組みになっていれば、

  • システム内に余計なデータが持ち込まれない
  • 自分が間違えてデータに余計なデータを入れてしまったとしても、外に出ていく前にエラーで止める

という、2つの恩恵を受けられます
つまり、strictを使うことで、「システムが扱うべき形以外は、存在できない世界」を作ることができるわけです。

問題点は仕組みで潰しておいたほうが何かと便利なので、こんな感じの仕組みを入れていただくと、安全性が一段階向上するかなと思います。

最後に

この仕組み自体はサンプルコードなので、欲しい人は勝手に持っていってね!って感じなんですが
先日リリースしたライブラリに上手くはめ込むと、Pipeline処理の間にデータのゲートを実装したりできて便利そうだなぁと思っています。
ライブラリ化もしようかなとは思っていますが、まずはサンプルコードの形で。
誰がどこかで使ってくれたら嬉しいです。

Discussion