Zod のスキーマを HTML フォームに合わせやすくする・ついでにシングル HTML ファイルで TypeScript React TSX を書く

最近 Zod を使い始めました。細かくバリデーションを実装できる他、バリデーションの前後にトリムや型変換などの処理も組み合わせられて便利です。クライアントサイドとサーバサイドを同じ TypeScript で実装していて、バリデーションコードを共有したい場合にも最適です。

そんな Zod は、z.infer という機能で TypeScript 向けに型定義を出力できます。

コレを使えば、

などを Zod スキーマから生成できるので、似たような型定義を複数宣言したりせずに一元管理できるようになります。

Zod スキーマで number 型を扱う時に HTML フォームとの相性が悪い

ところで、今回は React を例にしますが、HTML フォームでの入力値 (event.target.value) は常に string 型になります。コレは input[type="text"] に限らず input[type="number"] の場合も同じで、

typeof document.querySelector('input[type="number"]').value

で確認すると必ず string となります。

つまり、以下のように number 型を格納したい Zod スキーマを用意して、特に考慮せずに TypeScript React でフォームを書いていくと、setForm 部分で 型 'string' を型 'number' に割り当てることはできません。 という型不一致エラーが出てしまいます。

const MyComponent = () => {
  const userSchema = z.object({
    name: z.string(),
    age : z.coerce.number()  // `number` 型を格納したい
  });
  type User = z.infer<typeof userSchema>;
  
  const [form, setForm] = useState<User>({} as User);
  
  // バリデーションして型変換も済ませたら、適宜 POST するようなイメージ
  const onSubmit = async () => {
    const parsed = userSchema.safeParse(form);
    if(!parsed.success) return alert('フォーム入力にエラーがあります');
    
    const user: User = parsed.data;
    console.log(typeof user.age);  // → `number`
  };
  
  return (
    <div>
      <input type="text"   value={form.name} onChange={event => setForm(prevForm => ({ ...prevForm, name: event.target.value }))} />
      <input type="number" value={form.age}  onChange={event => setForm(prevForm => ({ ...prevForm, age : event.target.value }))} />
      {/* ↑ ココの `setForm()` が型不一致エラーになる */}
    </div>
  );
};

実際のところ、ビルド後は TypeScript の型情報は無視されるので、テキトーに age: event.target.value as unknown as number といった風に型変換して TypeScript を誤魔化してしまえば、実行時は string な値がオブジェクトに設定されるだけで正常に動作します。Zod の safeParse() などを通してバリデーション時に型変換を済ませれば、最終的な parsed.data.agenumber 型で取り扱えます。

…しかし、せっかく TypeScript で書いてるというのに、なんだか気持ち悪いですよね…。

フォーム用のユーティリティ型を作る

そこで、いっそのことフォーム入力時は string 型としてとりあえず値を格納できるようにし、Zod の safeParse() に各種チェックと型変換を任せることにしようと思います。そのために作ったユーティリティ型が、以下の Formify というモノです。

/** 主に `number` 型のプロパティに対して `string` 型も許容するようにするユーティリティ型 */
type Formify<T> = {
  [K in keyof T]:
    // 元が `number` 型なプロパティに `string` 型を含めて曖昧な型にする
    [T[K]] extends [string | number | null | undefined] ? string | number | null | undefined :
    // `boolean` はそのままにする (チェックボックス向けの考慮)
    [T[K]] extends [boolean] ? boolean :
    // その他オブジェクトなどはそのままの型としてみなす
    T[K];
};

TypeScript の型パズルは未だに何をしているのかちっとも分かってないのですが、コレで動きました (爆)。

コレを用いて、再び React フォームを作ってみます。今度は Zod の preprocess() も使って、入力値をイイカンジに変換することでうまく safeParse() に持ち込めるようにしています。

const MyComponent = () => {
  const userSchema = z.object({
    name: z.preprocess(value => value == null ? '' : String(value)                              , z.string().trim().min(1)),
    age : z.preprocess(value => value == null || String(value).trim() === '' ? undefined : value, z.coerce.number())
  });
  type User = z.infer<typeof userSchema>;
  
  /** 前述の Formify を利用して `number` 型のプロパティに `string` 型も許容するような曖昧な型を生成する */
  type UserForm = Formify<User>;
  const [form, setForm] = useState<UserForm>({} as UserForm);
  
  const onSubmit = async () => {
    const parsed = userSchema.safeParse(form);  // バリデーションを行う
    if(!parsed.success) return alert('フォーム入力にエラーがあります');
    
    const user: User = parsed.data;  // ココでは元の `User` 型になる
  };
  
  return (
    <div>
      <input type="text"   value={form.name} onChange={event => setForm(prevForm => ({ ...prevForm, name: event.target.value }))} />
      <input type="number" value={form.age}  onChange={event => setForm(prevForm => ({ ...prevForm, age : event.target.value }))} />
      {/* ↑ ココで型不一致エラーが出なくなる */}
    </div>
  );
};

z.infer で生成した元の User 型と、ユーティリティ型の Formify で生成した UserForm 型は、それぞれ以下のようになっています。

type User = {
  name: string;
  age : number;
};

type UserForm = {
  name: string | number | null | undefined;
  age : string | number | null | undefined;
};

そして、z.preprocess()undefinednull・空文字などを事前処理することで、safeParse() に持ち込む userForm オブジェクトの値を整理しています。

z.preprocess() も重要

string 型のプロパティから見てみます。

const name = z.preprocess(value => value == null ? '' : String(value), z.string().trim().min(1));

string 型プロパティは、undefinednull を受け取った場合は空文字 '' にし、その他は String() コンストラクタを通して、全ての値が必ず string 型になるよう事前変換しています。その後、z.string().trim().min(1) と繋げていくことで、空文字を許容せず、必須入力項目として処理されるようにしています。

min(1) がないと空文字でも OK となり、実質的・感覚的に optional() を指定した時と同等の挙動になります。

続いて number 型のプロパティを見てみます。

const age = z.preprocess(value => value == null || String(value).trim() === '' ? undefined : value, z.coerce.number());

number 型プロパティは、z.coerce.number() による型変換部分に罠があります。コレは Number() コンストラクタの挙動による言語仕様が起因しているのですが、

は全て、結果が 0 になるという挙動をします。

つまり、フォームとして未入力である空文字 '' の時は、「0 が入力された」ものとして解釈されてしまうことになります。

一方で、

NaN に変換される仕様です。

Number() (実質的に Undefined が渡ったように思える) は 0 になるのに、Number(undefined) と書くと NaN になるのが絶妙にモニャりますが、いずれにせよこの Number(undefined)NaN になるという挙動を利用して preprocess() を組み立てます。

すなわち、null・空文字 (= 未入力状態) が渡された場合は、0 と解釈されてしまうのを防ぐため、明示的に undefined に変換します。それ以外は素通し (undefined が渡された場合は undefined のまま素通し) とすることで、ようやく z.coerce.number() に辿り着いて number 型への型変換が行われ、数値になるか、NaN になるかが決定します。NaN になればバリデーションエラーですので、「未入力の類は許容せず、数字を入力する項目」が出来上がります。

当然ながら、ユーザがフォームに 0 を入力した場合と、未入力にした場合とはキチンと切り分けられますので、この状態で 0 が入力できなくなるような不都合は発生しません。

ついでに : シングル HTML ファイル内に TypeScript React (TSX) を書く

以上で、個人的・Zod 活用術のお話は終了です。今回はフォームの入力値管理に原始的な useState を使っており、その内容も配列やネストされたオブジェクトなど複雑なモノは取り扱っていません。ですので、もし紹介した Formify で足りない部分がありましたら、また教えてください。

以降は超絶ついでの話なのですが、自分は技術的興味の一環として、単一の HTML ファイル内に TypeScript コードを書いて、事前のトランスパイルなしに実行できないかなーという夢を以前から抱いています。

ということで、今回の Zod にまつわる検証コードも、TypeScript・React を組み合わせて実装し、シングル HTML ファイル内にブチ込んでクライアントサイドでページロード時にトランスパイルさせるようにしました。開発者側で事前ビルドしたモノが一切なく、CDN さえ機能していれば HTML ファイル以外に依存するファイルがないので、サンプル配布に向いているかと思います。

React と Zod は ES Modules として Skypack CDN から import しています。そうして script 要素内に記述した TypeScript・TSX コードは、同じく CDN から読み込んでいる Babel がトランスパイルしてくれています。

当然、このような使い方は本番環境向けではありませんが、個人的にやりたいことができて満足です。w