@k8ordo/form

フィールド

スキーマの末端 1 つが、入力要素 1 つになります。このページは zod から HTML への対応表です。field() が返すもの、型ごとに導かれる属性、ネスト・繰り返し行・チェックボックス群・ファイルの名前の付き方、そして formFields が受け付けないスキーマを扱います。

formFields が返すもの

formFields(schema) の結果は 4 つの部分からなる JSON で、useForm にはこの結果をまるごと渡します。スキーマの代わりに defineForm の定義を渡すこともできます。

  • fields — パスごとの DerivedFieldinput(広げる属性)、messages(ValidityState のフラグ、つまり ValidityFlag ごとの文言)、secret(入力値を返してはいけない欄か)を持ちます。
  • arrays — 繰り返し行ごとの DerivedArraypathminItemsmaxItems と、1 行ぶんの欄の説明 item を持ちます。
  • rulesdefineForm で宣言した複数の欄にまたがる検証。ただのデータです。
  • dropped — HTML の属性にできず、ブラウザでは検査されない検証の一覧。

たとえば z.string().min(1, …).max(120, …)title は、次のデータになります。

{
  "input": {
    "name": "title",
    "required": true,
    "type": "text",
    "minLength": 1,
    "maxLength": 120
  },
  "messages": {
    "valueMissing": "Enter a title",
    "tooShort": "Enter a title",
    "tooLong": "Use 120 characters or fewer"
  },
  "secret": false
}

field(path) が返すもの

form.field(path) は、導かれた属性に今のフォームの状態を重ねた FieldView を返します。

メンバー意味
inputFieldInput入力要素に広げる属性。name は常にあり、残りはスキーマから導かれた制約属性です。statevalues があるとき(parseForm が返した結果ならいつでも)は、返ってきた入力値が defaultValue(チェックボックスなら defaultChecked)として入ります。
errorstring | undefined表示するメッセージ。ブラウザ側のメッセージがあればそれを、無ければその欄が編集されるまでサーバーのエラーを返します。
invalidbooleanerror !== undefinedaria-invalid やスタイルに使います。
requiredboolean導かれた required。ラベルに必須の印を付けるときに使います。

既存の値を編集するフォームでは、自分の defaultValueinput の展開より前に書きます。statevalues が無いうちは inputdefaultValue を持たないので自分の値が使われ、parseForm の結果が返ってきたあとは、返ってきた入力値が上書きします。

// src/routes/talks/[id]/edit/_parts/edit-talk-form.tsx
'use client';

import { useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import { useActionState } from 'react';

import { updateTalk } from './actions';

type Props = {
  fields: FormFields<'title', never>;
  talk: { title: string };
};

export function EditTalkForm({ fields, talk }: Props) {
  const [state, formAction] = useActionState(updateTalk, {});
  const form = useForm(fields, state);
  const title = form.field('title');

  return (
    <form {...form.props} action={formAction}>
      <label>
        Title
        <input defaultValue={talk.title} {...title.input} />
      </label>
      {title.error !== undefined && <p>{title.error}</p>}
      <button type="submit">Save</button>
    </form>
  );
}

スキーマに無いパスはコンパイルで止まりますが、型を迂回して渡した場合も field() は実行時に例外を投げます。

zod の型と導かれる属性

変換は z.toJSONSchema の出力と zod 自身のチェックを突き合わせて行います。表の属性は input に入るもので、name は省いています。

スキーマinput補足
z.string()type: 'text''' を受け付けるので required は付きません。
z.string().min(1).max(120)type: 'text', required: true, minLength: 1, maxLength: 120.length(4)minLengthmaxLength の両方になります。
z.email()type: 'email', required: truezod のメール用正規表現はブラウザの v フラグでコンパイルできないため pattern にはせず、dropped に載ります。ブラウザ側は type="email" の検査だけが働きます。
z.url()type: 'url', required: trueブラウザ側は type="url" の検査が働きます。
z.string().regex(/^[a-z]+$/u)type: 'text', required: true, pattern: '^[a-z]+$'^…$ で囲まれ、フラグが無いか u だけで、v フラグでもコンパイルできる正規表現だけが pattern になります。それ以外は dropped に載ります。
z.email().regex(/^[a-z@.]+$/u)type: 'email', required: true形式に重ねたチェックがあっても、type は形式のものです。重ねた正規表現は、ブラウザが走らせる正規表現がそれ 1 つのとき(z.url().lowercase())だけ pattern になり、形式も正規表現を持つときは dropped に載ります。
z.iso.date()type: 'date', required: true日付の正規表現は出しません。type="date" のほうが厳しく制約します。
z.iso.time()type: 'time', required: truetype="time"pattern を読まないので、正規表現は dropped に載ります。
z.iso.datetime({ local: true })type: 'datetime-local', required: truedatetime-local が送る形(タイムゾーンなし)を受け付けるスキーマです。
z.iso.datetime()type: 'text', required: true, pattern: '^(…)$'タイムゾーンを要求するので、datetime-local では満たせません。type="text" に落として dropped に載せ、日時の正規表現を pattern にします。
z.uuid()type: 'text', required: true, pattern: '^(…)$'専用の type が無い形式(z.uuid()z.ipv4()z.iso.duration() など)はテキスト欄になり、正規表現はブラウザが同じ意味で読めるときだけ pattern になります。
z.coerce.number()type: 'number', step: 'any', required: true空の数値欄は 0 ではなく未入力として届くので、required になります。
z.coerce.number().int().min(1).max(10)type: 'number', step: 1, min: 1, max: 10, required: true.int() が持つ安全な整数の範囲は属性にしません。整数の .positive().gt(0)min: 1 になりますが、小数の .gt() / .lt() は境界を含まないため dropped に載ります。
z.coerce.number().multipleOf(0.5)type: 'number', step: 0.5, required: true.multipleOf()step になります。
z.coerce.number().optional()type: 'number', step: 'any'未入力を受け付けるので required は付きません。.default(5) も同じです。
z.boolean()type: 'checkbox'チェックされていないボックスは false として届き、z.boolean() はそれを受け付けます。
z.literal(true, '…')type: 'checkbox', required: true同意のチェックボックス。未チェックを拒むので required になり、文言は zod のものです。
z.enum(['free', 'team'])required: truetype を持ちません。<select> かラジオボタンで描きます。
z.enum(['free', 'team']).optional(){}未選択を受け付けるので required は付きません。.default() も同じです。
z.array(z.enum(['a', 'b'])){}名前だけを持つチェックボックス群です。.min() / .max()dropped に載ります。
z.file().mime(['image/png'])type: 'file', required: true, accept: 'image/png'.mime()accept になりますが、ファイル選択の候補を絞るだけでブラウザは種類を検査しないので、dropped にも載ります。サイズの .min() / .max()dropped に載ります。
z.file().optional()type: 'file'選ばれなかったファイル欄は未入力として届くので、.optional() なら required は付きません。
z.string().min(8).meta({ input: 'password' })type: 'password', required: true, minLength: 8secret: true になり、入力値は返されません。
z.string().min(2).nullable()type: 'text', required: trueどちらの枝を検査すべきか決まらないので、中の制約は属性にならず dropped に載ります。union も同じです。
z.coerce.date()type: 'text', required: true文字列を読むスキーマなので残りますが、制約は読み取れず dropped に載ります。空の z.coerce.bigint() の欄は数値と同じく未入力として届くので、0n にはならず、未入力を拒むスキーマなら required になります。
z.coerce.bigint()type: 'text', required: true文字列を読むスキーマなので残りますが、制約は読み取れず dropped に載ります。空の z.coerce.bigint() の欄は数値と同じく未入力として届くので、0n にはならず、未入力を拒むスキーマなら required になります。
z.string().transform(…)type: 'text'スキーマから制約を読み取れないので、type="text" だけを出して dropped に載せます。検査はサーバーでだけ行われます。
z.custom<string>(…)type: 'text'スキーマから制約を読み取れないので、type="text" だけを出して dropped に載せます。検査はサーバーでだけ行われます。

required の決まり方

JSON Schema の required は「キーがある」という意味ですが、フォームはすべての欄について何かを送ります。何を送るかは入力要素で決まるので、formFields はその「空の送信」をスキーマに渡してみて、拒まれたときだけ required を付けます。parseForm もまったく同じ値をスキーマに渡すので、ブラウザとサーバーの判断は食い違いません。

入力要素触られなかったときにスキーマが受け取る値
テキスト系(text・email・url・date など)''
チェックボックスfalse(チェックされていれば value に関係なく true
数値(z.coerce.bigint() のテキスト欄を含む)undefined(空の数値欄も、名前の無い 0 バイトのファイルも「未入力」)
ファイルundefined(空の数値欄も、名前の無い 0 バイトのファイルも「未入力」)
<select> / ラジオボタンundefined<select> のプレースホルダーが送る '' も、何も送らない未選択のラジオボタンも「未選択」です(undefined を受け付けない enum なら検証エラー)。
チェックボックス群[]

テキスト欄の .optional() は、欄を任意にしません。テキスト欄は undefined ではなく '' を送るので、z.string().min(1).optional()required のままで、サーバーでも空欄を拒みます。空欄を許すには、z.string().max(200) のように '' を受け付けるスキーマを書きます。

メッセージは zod から取る

messages は、特定のチェックだけを落とす値をスキーマに通し、zod が返した文言を集めたものです。欄の横に出る文言は zod 自身が出す文言そのもので、サーバーの parseForm がそのチェックに使う文言と同じです。

文言を変えるには、.min(1, '…') のようにチェックに書くか、z.config(z.locales.ja()) で zod のロケールを読み込みます。どちらも formFields を呼んだ時点のものが使われます。

ブラウザ自身の文言は使いません。ロケールに依存して制御できないからです。zod から文言が得られなかったフラグでは、クライアントには何も表示されず、判断はサーバーに任されます。

ネストしたオブジェクト

ネストした欄はドット区切りのパスで取り出し、そのパスがブラウザの送る name にもなります。エラーのキーも同じ address.city です。

// src/routes/profile/_parts/profile-schema.ts
import * as z from 'zod';

export const profileSchema = z.object({
  name: z.string().min(1, 'Enter your name'),
  address: z.object({
    city: z.string().min(1, 'Enter a city'),
    postalCode: z
      .string()
      .regex(/^[0-9]{3}-[0-9]{4}$/u, 'Use the 123-4567 format'),
  }),
});
// src/routes/profile/_parts/profile-form.tsx
'use client';

import { useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import { useActionState } from 'react';

import { saveProfile } from './actions';

type Props = {
  fields: FormFields<'name' | 'address.city' | 'address.postalCode', never>;
};

export function ProfileForm({ fields }: Props) {
  const [state, formAction] = useActionState(saveProfile, {});
  const form = useForm(fields, state);
  const name = form.field('name');
  const city = form.field('address.city');
  const postalCode = form.field('address.postalCode');

  return (
    <form {...form.props} action={formAction}>
      <input {...name.input} aria-label="Name" />
      {name.error !== undefined && <p>{name.error}</p>}
      <input {...city.input} aria-label="City" />
      {city.error !== undefined && <p>{city.error}</p>}
      <input {...postalCode.input} aria-label="Postal code" />
      {postalCode.error !== undefined && <p>{postalCode.error}</p>}
      <button type="submit">Save</button>
    </form>
  );
}

.optional().default() の付いたオブジェクトも中の欄を保ちます。ただし中の入力要素は常に描いてください。名前が送られてこなければ parseForm は結線の誤りとして例外を投げます。

繰り返し行: array(path)

オブジェクトの配列は form.array(path) で扱い、ArrayView が返ります。React の state が持つのは各行の識別子だけで、値は DOM にあるので、行の追加や削除で値を React に写すことはありません。

メンバー意味
rowsRowView[]各行。key は React の key に、index は今の位置として使います。field(itemKey) で行の中の欄を取り出し、remove() でその行を消します。itemKey はただの文字列でコンパイル時には検査されず、行に無いキーを渡すと描画時に例外を投げます。
add() => void末尾に行を足します。
canAddboolean行数がスキーマの .max() に達すると false
canRemoveboolean行数がスキーマの .min() 以下なら false
errorstring | undefined配列そのものへのサーバーのエラー(.min(1, …) を満たさないときなど)。
// src/routes/orders/new/_parts/order-schema.ts
import * as z from 'zod';

export const orderSchema = z.object({
  items: z
    .array(
      z.object({
        name: z.string().min(1, 'Enter an item'),
        quantity: z.coerce
          .number('Enter a quantity')
          .int('Use a whole number')
          .min(1, 'Order at least one'),
      }),
    )
    .min(1, 'Add at least one item')
    .max(10),
});
// src/routes/orders/new/_parts/order-form.tsx
'use client';

import { useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import { useActionState } from 'react';

import { placeOrder } from './actions';

type Props = {
  fields: FormFields<never, 'items'>;
};

export function OrderForm({ fields }: Props) {
  const [state, formAction] = useActionState(placeOrder, {});
  const form = useForm(fields, state);
  const items = form.array('items');

  return (
    <form {...form.props} action={formAction}>
      {items.rows.map((row) => {
        const name = row.field('name');
        const quantity = row.field('quantity');
        return (
          <fieldset key={row.key}>
            <legend>{`Item ${String(row.index + 1)}`}</legend>
            <input {...name.input} aria-label="Item" />
            {name.error !== undefined && <p>{name.error}</p>}
            <input {...quantity.input} aria-label="Quantity" />
            {quantity.error !== undefined && <p>{quantity.error}</p>}
            {items.canRemove && (
              <button onClick={row.remove} type="button">
                Remove
              </button>
            )}
          </fieldset>
        );
      })}
      {items.error !== undefined && <p>{items.error}</p>}
      {items.canAdd && (
        <button onClick={items.add} type="button">
          Add an item
        </button>
      )}
      <button type="submit">Order</button>
    </form>
  );
}
  • 最初の行数は、送信後なら state.rows の値、無ければ .min()、それも無ければ 0 です。JavaScript が無いときに表示される行数もこれで、追加や削除のボタンは動きません。
  • 行の欄の nameitems[0].name のように添字を含み、エラーのキーも同じです。行を消すと後ろの行が詰まり、ブラウザ側で出したメッセージも行と一緒に移ります。値は DOM にあるので消えません。state.errors のサーバーのエラーは添字を振り直さないので、上の行を消すと、まだ編集していないエラーはその添字になった行に出ます。
  • parseForm は送られてきた最大の添字から行数を数え、その範囲の行がすべて揃っていることを求めます。行数は .max()(無ければ 1000)で頭打ちになるので、偽造された大きな添字でサーバーが大きな配列を確保することはありません。
  • z.array(z.string()) のようなスカラーの配列では、行の欄に名前がありません。row.field() と引数なしで呼び、nametags[0] になります。
  • 繰り返しの中の繰り返しは name の添字が一意に決まらないので、formFieldsparseForm が例外を投げます。型はこのスキーマを通してしまうので、気づくのは導出するときです。

選択肢: <select> とラジオボタン

z.enum()type を持たない input になります。<select> には属性をそのまま広げられます。

ラジオボタンには広げず、namerequired を渡します。statevalues があるときの input には、返ってきた値が defaultValue として入り、それぞれのボタンが持つ value とぶつかるからです。選ばれていた値は state.values から defaultChecked で戻します。

// src/routes/signup/_parts/plan-schema.ts
import * as z from 'zod';

export const planSchema = z.object({
  region: z.enum(['asia', 'europe'], 'Choose a region'),
  plan: z.enum(['free', 'team'], 'Choose a plan'),
});
// src/routes/signup/_parts/plan-form.tsx
'use client';

import { useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import { useActionState } from 'react';

import { choosePlan } from './actions';

const PLANS = ['free', 'team'] as const;

type Props = {
  fields: FormFields<'region' | 'plan', never>;
};

export function PlanForm({ fields }: Props) {
  const [state, formAction] = useActionState(choosePlan, {});
  const form = useForm(fields, state);
  const region = form.field('region');
  const plan = form.field('plan');

  return (
    <form {...form.props} action={formAction}>
      <select {...region.input} aria-label="Region">
        <option value="">Choose a region</option>
        <option value="asia">Asia</option>
        <option value="europe">Europe</option>
      </select>
      {region.error !== undefined && <p>{region.error}</p>}

      <fieldset>
        <legend>Plan</legend>
        {PLANS.map((option) => (
          <label key={option}>
            <input
              defaultChecked={state.values?.plan === option}
              name={plan.input.name}
              required={plan.input.required}
              type="radio"
              value={option}
            />
            {option}
          </label>
        ))}
      </fieldset>
      {plan.error !== undefined && <p>{plan.error}</p>}

      <button type="submit">Continue</button>
    </form>
  );
}

<option value=""> のプレースホルダーを置くと、選ばれていない <select>'' を送り、スキーマには未選択(undefined)として届きます。enum がそれを拒むので欄は required になります。JavaScript が無ければブラウザが送信を止めます。JavaScript があれば、選ばれていない <select> は欄を離れたときに valueMissing としてスキーマの文言を出し、サーバーでは parseForm が未選択を拒みます。

.optional().default() の enum は未選択を受け付けるので、required は付きません。プレースホルダーのままの <select> も、何も選ばれていないラジオボタンも、そのままサーバーを通ります(.default() ならその値になります)。

チェックボックスとチェックボックス群

z.boolean() は 1 つのチェックボックスです。チェックされていれば true、されていなければ false として届くので、value 属性は関係ありません。同意のように未チェックを拒みたいときは z.literal(true, '…') と書きます。

列挙値の配列は、決まった選択肢から複数を選ぶチェックボックス群です。すべてのボックスが 1 つの名前を共有するので、繰り返し行ではなく 1 つの欄として field() で取り出します。

// src/routes/signup/_parts/preferences-schema.ts
import * as z from 'zod';

export const preferencesSchema = z.object({
  newsletter: z.boolean(),
  terms: z.literal(true, 'Agree to the terms to continue'),
  topics: z
    .array(z.enum(['react', 'css', 'a11y']))
    .min(2, 'Pick at least two topics'),
});
  • parseForm はチェックされたボックスの値をすべて読みます。1 つもチェックされていなければ [] で、結線の誤りにはなりません。
  • .min(2) は HTML の属性にできません(群に required を付けると「すべてにチェック」の意味になります)。dropped に載るので、ブラウザでも検査するには minChecked を宣言します。 ルールの宣言はバリデーションのページにあります。
  • 群のボックスには name だけを渡します。state.values に返る入力値は、チェックが 1 つでも 0 個でも配列(['a'] / [])なので、そこから各ボックスの defaultChecked を決めます。
// src/routes/signup/_parts/preferences-form.tsx
'use client';

import { useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import { useActionState } from 'react';

import { savePreferences } from './actions';

const TOPICS = ['react', 'css', 'a11y'] as const;

type Props = {
  fields: FormFields<'newsletter' | 'terms' | 'topics', never>;
};

export function PreferencesForm({ fields }: Props) {
  const [state, formAction] = useActionState(savePreferences, {});
  const form = useForm(fields, state);
  const newsletter = form.field('newsletter');
  const terms = form.field('terms');
  const topics = form.field('topics');
  const echoed = state.values?.topics;
  const checked = Array.isArray(echoed) ? echoed : [];

  return (
    <form {...form.props} action={formAction}>
      <label>
        <input {...newsletter.input} />
        Send me the newsletter
      </label>

      <label>
        <input {...terms.input} />
        I agree to the terms
      </label>
      {terms.error !== undefined && <p>{terms.error}</p>}

      <fieldset>
        <legend>Topics</legend>
        {TOPICS.map((option) => (
          <label key={option}>
            <input
              defaultChecked={checked.includes(option)}
              name={topics.input.name}
              type="checkbox"
              value={option}
            />
            {option}
          </label>
        ))}
      </fieldset>
      {topics.error !== undefined && <p>{topics.error}</p>}

      <button type="submit">Save</button>
    </form>
  );
}

ファイル

z.file()type="file" になり、.mime([…])accept になります。accept はファイル選択の候補を絞るだけなので、種類を検査するのはサーバーだけで、そのことは dropped に載ります。

// src/routes/profile/_parts/avatar-schema.ts
import * as z from 'zod';

export const avatarSchema = z.object({
  avatar: z
    .file('Choose an image')
    .mime(['image/png', 'image/jpeg'])
    .max(1_000_000, 'Use an image of 1 MB or less'),
});
  • 選ばれなかったファイル欄も、名前の無い 0 バイトのファイルを送ります。z.file() はそれを本物のファイルとして受け付けてしまうので、parseForm は未入力として渡し、required の意味を保ちます。
  • サイズの .min() / .max() はバイト数で、対応する HTML 属性はありません。サーバーでだけ検査され、dropped に載ります。ファイルは state.values に返されません。ファイル欄に値を戻せるブラウザは無いからです。
  • 1 つの欄で複数のファイルを受けることはまだできません。z.array(z.file()) は 1 ファイルずつの繰り返し行になります。

パスワード

parseForm はやり直しのために入力値を返しますが、パスワードとして印を付けた欄は除きます。印を付けた欄は type="password" になり、secrettrue になります。印は、zod では .meta({ input: 'password' }) で付けます。zod/mini には .meta() メソッドが無いので、.check()z.meta({ input: 'password' }) を渡すか、スキーマを z.globalRegistry に登録します。レジストリへの登録はどちらの入口でも動きます。

import * as z from 'zod';

export const loginSchema = z.object({
  email: z.email('Enter your email address'),
  password: z
    .string()
    .min(8, 'Use at least 8 characters')
    .meta({ input: 'password' }),
});
import * as z from 'zod/mini';

export const loginSchema = z.object({
  email: z.email('Enter your email address'),
  password: z
    .string()
    .check(
      z.minLength(8, 'Use at least 8 characters'),
      z.meta({ input: 'password' }),
    ),
});

入力要素を描かない部品: HiddenValue

リッチテキストエディタや外部のコンボボックスは <input name> を描かないので、FormData に値が載りません。値を React の state から送信時に取り出すのではなく、隠し入力に置いておきます。値はふつうのフォームの項目なので、送信時に取り出すコードなしで、ほかの欄と一緒に FormData に載ります。

// src/routes/posts/new/_parts/post-form.tsx
'use client';

import { HiddenValue, useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import { useActionState, useState } from 'react';

import { createPost } from './actions';
import { RichTextEditor } from './rich-text-editor';

type Props = {
  fields: FormFields<'title' | 'body', never>;
};

export function PostForm({ fields }: Props) {
  const [state, formAction] = useActionState(createPost, {});
  const form = useForm(fields, state);
  const [body, setBody] = useState('');
  const title = form.field('title');
  const bodyField = form.field('body');

  return (
    <form {...form.props} action={formAction}>
      <input {...title.input} aria-label="Title" />
      {title.error !== undefined && <p>{title.error}</p>}
      <RichTextEditor onChange={setBody} value={body} />
      <HiddenValue name="body" value={body} />
      {bodyField.error !== undefined && <p>{bodyField.error}</p>}
      <button type="submit">Publish</button>
    </form>
  );
}

props を広げる関数ではなくコンポーネントなのは、React が制御された値を書き換えても DOM のイベントが起きないからです。HiddenValue は値が変わるたびに input イベントを出すので、複数の欄にまたがる検証や isDirty がキー入力と同じように気づきます。マウント時の値を変更の有無の基準として覚えます。

隠し入力はブラウザの制約検証の対象外なので、この欄のメッセージはクライアントでは出ません。スキーマの検証はサーバーで行われ、エラーは field('body').error で読めます。フォームのリセットでも値は戻りません。値は呼び出し側の state だからです。

パスはコンパイル時に検査される

formFields はスキーマの型から有効なパスを導きます。打ち間違いはクリックして見つけるものではなく、ビルドエラーになります。オブジェクトの配列は array() に、列挙値の配列(チェックボックス群)は field() に振り分けられ、取り違えも型で止まります。複数の欄にまたがる検証も同じパスで型付けされます。

  • form.field('titel') — スキーマに無い欄です。
  • form.field('items') — オブジェクトの配列は array() で取り出します。
  • form.array('address') — オブジェクトで、配列ではありません。
  • form.array('topics') — チェックボックス群は 1 つの欄で、field() で取り出します。
  • sameAs('confrim', 'password', …) — ルールもスキーマのパスで型付けされるので、打ち間違いはコンパイルで止まります。

ブラウザで検査されない検証: dropped

HTML の属性にできない検証は、黙って捨てずに droppedDroppedCheck{ field, reason })として返します。これらはサーバーでは検査され、ブラウザでは検査されません。production 以外では、同じ一覧を console.warn でスキーマごとに一度だけ出すので、戻り値を読み忘れても気づけます。

dropped に載るのは次のものです。

  • ルートのオブジェクトに付けた .refine() などのチェック(field(schema) で、件数だけが分かります)
  • ^…$ で囲まれていない、u 以外のフラグを持つ、または v フラグでコンパイルできない正規表現(z.email() を含む)
  • 1 つの文字列に重ねた複数の正規表現(pattern 属性は 1 つしか持てません)。z.email().regex(…) のように形式が自分の正規表現を持つときも同じです
  • pattern を読まない type="time" に付いた正規表現(z.iso.time() を含む)と、type="date" / datetime-local に重ねた正規表現
  • 小数の .gt() / .lt() のような境界を含まない範囲
  • タイムゾーンを要求する z.iso.datetime()
  • .nullable() や union の中の制約
  • .transform()・JSON Schema で表せない型への .pipe()z.custom() などで制約を読み取れなくなった欄
  • .mime() の種類の制限(accept はファイル選択の候補を絞るだけです)
  • チェックボックス群の個数の制限と、ファイルのサイズの制限

まだ dropped に載らないものがあります。1 つの欄、ネストしたオブジェクト、繰り返し行に付けた .refine() / .superRefine() です(数えるのはルートのオブジェクトのチェックだけです)。これもサーバーでは検査されます。

複数の欄にまたがる検証をブラウザでも走らせるには、.refine() の代わりに defineForm のルールを使います。 ルールの宣言はバリデーションのページにあります。

reason の文言は日本語で書かれています。

導出時に拒否されるスキーマ

フォームで表せないスキーマは、formFields がパスと理由を付けて例外を投げます。入力を黙って読み違えるフォームや、どんな送信でも必ず失敗するフォームを作らないためです。parseForm も同じ走査を使うので、同じ例外を投げます。

  • z.number()z.literal(1) — 値は文字列で届くので、どんな送信も通りません。z.coerce.number() を使います。
  • z.date()z.bigint()z.nan() — 文字列を受け付けません。z.coerce.date()z.coerce.bigint() は文字列を読むので残ります。
  • z.stringbool() — チェックボックスとして導かれますが、parseForm はチェックボックスを真偽値で渡すので、どんな送信も通りません。z.boolean() を使います。
  • z.record() — キーの構成を列挙できません。
  • z.tuple() — 要素の型が一様でなく、繰り返し行にできません。
  • 繰り返しの中の繰り返し(繰り返し行の中の、列挙値以外の配列)
  • .nullable() のオブジェクトや配列、オブジェクトや配列を含む union
  • .[] を含むキー — name の区切りとぶつかります。

文字列を拒む z.custom() は中身を読めないので拒否できず、テキスト欄として dropped に載り、サーバーで失敗します。