Form utilities written against one validation library — a submit helper that calls schema.safeParse, an error mapper that reads ZodError.issues — lock the whole codebase to that library, and switching later means touching every form.

Standard Schema is a small shared interface implemented by Zod (3.24+), Valibot (1.0+), ArkType and others. A schema from any of them exposes a ~standard property with a validate function and a common result shape. Form code written against that interface works with every compliant library. This page, part of choosing a schema validation library, builds form helpers on it.


Context and prerequisites

The interface, in essence:

  • schema["~standard"].version — 1.
  • schema["~standard"].vendor — "zod", "valibot", "arktype"…
  • schema["~standard"].validate(value) — returns a result, or a Promise of one (for async schemas): { value } on success, { issues } on failure.
  • Each issue has a message and an optional path: an array whose items are property keys or objects { key }.
  • Types: StandardSchemaV1.InferInput<S> and InferOutput<S> extract input and output types.

The ~ prefix keeps the property out of editor autocomplete for everyday use; it is meant for library and tooling authors — which is what a form layer is.

One interface, many libraries The form helpers — a submit wrapper, an error mapper and typed field bindings — call only the ~standard validate function and read issues with message and path. Zod, Valibot, ArkType and other compliant libraries each implement that interface on their schemas, so any of them can be passed to the same helpers. Form helpers submit, error mapper, typed fields. Call ~standard.validate only. ~standard interface validate(value) → { value } | { issues }. Sync or Promise. Compliant libraries Zod, Valibot, ArkType, … Each schema exposes ~standard.

The core pattern: library-agnostic validate and error mapping

import type { StandardSchemaV1 } from "@standard-schema/spec";   // types only; no runtime dependency

export type FieldErrors = Record<string, string>;

/** Normalise a Standard Schema issue path to "a.b.2.c". */
export function pathOf(issue: StandardSchemaV1.Issue): string {
  return (issue.path ?? [])
    .map((seg) => (typeof seg === "object" && seg !== null ? seg.key : seg))
    .map(String)
    .join(".");
}

/**
 * Validate with ANY compliant schema. Always returns a Promise so callers do
 * not need to care whether the schema is sync or async.
 */
export async function validateForm<S extends StandardSchemaV1>(
  schema: S,
  input: unknown,
): Promise<{ ok: true; value: StandardSchemaV1.InferOutput<S> } | { ok: false; errors: FieldErrors; formErrors: string[] }> {
  let result = schema["~standard"].validate(input);
  if (result instanceof Promise) result = await result;

  if (!result.issues) return { ok: true, value: result.value as StandardSchemaV1.InferOutput<S> };

  const errors: FieldErrors = {};
  const formErrors: string[] = [];
  for (const issue of result.issues) {
    const p = pathOf(issue);
    if (!p) formErrors.push(issue.message);   // root-level issue: no field to attach to
    else errors[p] ??= issue.message;         // first message per field
  }
  return { ok: false, errors, formErrors };
}

/** A typed submit wrapper usable with any library's schema. */
export function withSchema<S extends StandardSchemaV1>(
  schema: S,
  onValid: (value: StandardSchemaV1.InferOutput<S>) => Promise<void>,
  onInvalid: (errors: FieldErrors, formErrors: string[]) => void,
) {
  return async (input: StandardSchemaV1.InferInput<S>) => {
    const r = await validateForm(schema, input);
    if (r.ok) await onValid(r.value);
    else onInvalid(r.errors, r.formErrors);
  };
}
// The same helper, three libraries:
import { z } from "zod";
import * as v from "valibot";
import { type } from "arktype";

const zodSchema = z.object({ email: z.string().email("Enter an email like [email protected].") });
const valibotSchema = v.object({ email: v.pipe(v.string(), v.email("Enter an email like [email protected].")) });
const arkSchema = type({ email: "string.email" });

await validateForm(zodSchema, { email: "x" });      // { ok: false, errors: { email: "…" } }
await validateForm(valibotSchema, { email: "x" });  // same shape
await validateForm(arkSchema, { email: "x" });      // same shape (ArkType's own message)

Step-by-step walkthrough

  1. Depend on the spec’s types, not a library. @standard-schema/spec provides types only; your helpers have no runtime dependency on any validator.
  2. Call ~standard.validate and normalise sync and async. Awaiting the result when it is a Promise lets one code path serve every schema.
  3. Normalise paths. Path segments may be plain keys or { key } objects; flatten both to dotted strings your form uses, as in normalizing nested field error paths.
  4. Separate root issues from field issues. An issue with no path belongs to the form, not a field — route it to form-level messaging.
  5. Type inputs and outputs from the schema. InferInput types what the form holds; InferOutput types what submit receives, keeping the distinction from wiring React Hook Form to a Zod resolver.
  6. Keep library-specific features at the edges. Error maps, localisation and schema composition stay in library code; your form layer only validates and reads issues.

Why the interface is deliberately small

Standard Schema standardises validation results, not schema construction, error-message configuration or introspection. That is intentional: those areas are where libraries differ most and compete. For a form layer, the small surface is enough — validate, read messages and paths, infer types — and it is stable because it asks so little. What it does not give you is a way to read constraints (like “this field is required” or “max length 50”) to render hints or HTML attributes; code that needs that still talks to a specific library, or to a separate metadata layer.

What Standard Schema covers for forms Validating a value is covered. Reading issue messages and paths is covered. Inferring input and output types is covered. Async validation is covered because validate may return a promise. Configuring and localising messages is not covered and stays library-specific. Reading constraints such as required or max length to render HTML attributes is not covered. Building schemas is not covered; each library keeps its own API. Concern Covered? Otherwise validate a value yes issue messages and paths yes input and output types yes async validation yes (Promise) message config, i18n no library error maps read constraints (required, max) no library introspection build schemas no each library's API

Where library-agnostic helpers pay off

The benefit shows up in shared infrastructure rather than individual forms. A design system’s form components, an internal submit helper used by fifty forms, a testing utility that asserts “this input produces this field error”, an API client that validates responses — each of these would otherwise pick one validation library and impose it on every team. Written against Standard Schema, they let teams choose the library that suits their constraints (Valibot where bundle size matters, Zod where its ecosystem helps, ArkType where its type-level syntax fits) while sharing the same tooling. It also turns a future library migration from a codebase-wide rewrite into a per-schema change.


Failure modes and edge cases

1. Assuming synchronous results

Some schemas are async (they contain async refinements). Code that reads result.issues without awaiting will see a Promise and treat the input as valid. Always await.

2. Different messages per library

Default messages differ between libraries. If you swap libraries, set up that library’s message configuration so users see the same wording — see custom Zod error maps and localised messages.

3. Path segment types

Array indices appear as numbers, object keys as strings, and some libraries use symbols for special cases. String(seg) in the normaliser keeps them printable; decide how your form represents indices (dots versus brackets) and stick to it.

4. Older library versions

Standard Schema support requires recent versions (Zod 3.24+, Valibot 1.0+). Older schemas lack ~standard; detect with "~standard" in schema and fail loudly in development.

5. Transforms and output types

Libraries apply transforms before returning value. Your submit handler receives the transformed output, which may differ from what the form displays — trimmed strings, parsed numbers. Keep form display state separate from the validated output.

A submit through the library-agnostic helper The submit handler passes form values to withSchema. The helper calls the schema's ~standard validate function and awaits the result if it is a promise. If there are no issues, the typed output value goes to the onValid callback. Otherwise each issue's path is normalised to a dotted string; issues with a path become field errors, keeping the first message per field, and issues without a path become form-level errors. ~standard.validate(input) Any compliant library. Sync result or Promise. Await if needed One code path for both. Async refinements handled transparently. Issues? branch none → onValid(output) Output is typed with InferOutput. Normalise and route path → field errors no path → form errors First message per field.

Verification checklist


Frequently Asked Questions

Do form libraries already support Standard Schema?

Many do or are adding it: there are Standard Schema resolvers and adapters for popular form libraries, and newer libraries accept any compliant schema directly. Check your library’s docs; if it supports Standard Schema, you may not need custom helpers at all.

Does using Standard Schema cost performance?

No meaningful cost: ~standard.validate is a thin wrapper around the library’s own parse. The interface adds a function call and a result object.

Can I use it on the server too?

Yes. API frameworks can accept any Standard Schema for request validation, which lets client and server share schemas without agreeing on a single library across teams.


Related

← Choosing a Schema Validation Library