プログラマ向け圏論入門 1を見てからZodで考えてみた

に公開

この動画を全部見ました!ありがとうございます!!
https://www.youtube.com/watch?v=H_7DWBL4M6I

止めずに最後まで見てしまいました。面白かったです。

5月9日のフロントエンドカンファレンスでZodの話をするので、視聴後にZodのコードを眺めていたら、「Zodって圏における何だろう」と気になりました。

私自身この動画で初めて圏を知ったレベルなので、間違っているところは多々あると思いますが、考えてみることにしました。Haskellやモナドはまだ勉強していません。

この記事は、tsが好きかつ、動画を見て圏論について一緒に勉強したい人向けに書いています。

まずは集合と対象の違いをはっきりさせてみる?

一旦、集合について考えてみました。例えば、z.string()は文字列の集合です。
具体的には、こんな感じでしょうか。

  • "apple", "orange", "" ... といった無限個の要素を認める
  • Zodのparse()メソッドである値がその集合に「属しているか」を判定する

次に、z.string()が対象として扱っていいかを考えようとしました。でもその前に、対象は圏があるから対象なわけであって...と考えてしまいました。

結局、成り立つか分かりませんが、z.string()が対象である圏を考えて、成り立たつかどうかを考えてみることにしました

z.string()が対象である圏を考えるために、射を考える

動画の後半では、「人間関係」や「プログラミング経験が長い」かどうかという射を仮置きして、圏であるかどうかを判断しています。

自分もやってみました。ただ、途中から普通の集合の話になってきていそう...。

射①:スキーマ (A) でパース可能な値は、必ずスキーマ (B) でもパース可能である

簡単そうなので、ここから考えてみます。
例えば、

  • z.email()でパース可能な値はz.string()でパース可能
  • z.object({ name: z.string(), age: z.number() })でパース可能なオブジェクトはz.object({ name: z.string() })でもパース可能。

といった感じです。対象と射は上に書きましたね!

合成と結合律

A \subseteq B \subseteq C \subseteq D なら、V_A \subseteq V_D も成り立つので合成射が存在します。たとえば、

A: z.literal("admin@x.com") \subseteq B: z.email() \subseteq C: z.string() \subseteq D: z.any()

包含関係は順序を問わないので、(h \circ g) \circ fh \circ (g \circ f) も結局 A \to D の一本にできます。結合律を満たしてそうです。

コードで、AからBへの射を定義し、BからCへの射を定義してから合成を定義するとこう。

const composeSchema = <T, U, V>(f: z.ZodType<U, any, T>, g: z.ZodType<V, any, U>) => {
  return f.pipe(g);
};

const adminToEmail = z.literal("admin@x.com").pipe(z.string().email());
const emailToString = z.string().email().pipe(z.string());

const strictComposed = composeSchema(adminToEmail, emailToString);

ちなみに最初はpipe()で定義し、それ違うよと言われました。こちらは単にデータを流しているだけ。

const A = z.literal("admin@x.com");
const B = z.email();
const C = z.string();

const composed = A.pipe(B).pipe(C);
composed.parse("admin@x.com");

恒等射

V_A \subseteq V_A は当然成り立つので、任意の A に恒等射 \text{id}_A: A \to A が存在します。z.string()でパース可能な値はz.string()でパースできる。あんまり意味があるのかはわかりませんが...

じゃあこれは圏でいいですか?

これが圏であるかどうかを確定する前に、他のケースについても考えてみます。z.anyz.unknownz.transformです。

z.any()z.unknown()

どちらもあらゆる値を受け入れるスキーマなので、V_{\text{any}}V_{\text{unknown}}もすべての値の集合です。推論されるTypeScriptの型が変わるので別物ですが...

射で考えると、

  • 任意のAに対してV_A \subseteq V_{\text{any}}なので、射A \to \text{z.any()}は常に存在する
  • A \to \text{z.unknown()}も常に存在する

両方の射が存在するのですが、これはいいんですかね?

よく見ると射①の世界から見れば同じになりそう?でした。V_{\text{any}} = V_{\text{unknown}}でいいんでしょうか?

TypeScriptの推論型は違っても、Zod圏のレベルでは同じ万能スキーマで、互いに射が双方向に伸びている感じです。

そんでAIに聞いたら「終対象(terminal object)の匂いがする😁」と言われました

z.transform()は射①に入らない

例えば、z.string().transform((s) => s.length)は、文字列を受けて数値を返すスキーマです。ということで、射①にはパース可能な値の包含関係でないので、捉えられません。

ただこのz.transform()を無理やり射にしたいのなら、こんな定義になるでしょうか?

射②:(f: A \to B) を「(A)の検証を通った値を(B)が受け入れ可能な値に変換する関数


射②を一緒にするとどうなるか?そもそも射②だけで別の圏が建つのか?transformの合成は明らかに関数合成っぽいので...

この辺りは一旦別で考えてみようかなと思います(まだ考えがまとまっていません)

このへんはまだ全然整理できていないので、続きはまた別の記事で考えてみます。圏論の入り口で詰まりながらも、Zodを通して眺めるとちょっとだけ楽しい時間でした。

Discussion