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
messageand an optionalpath: an array whose items are property keys or objects{ key }. - Types:
StandardSchemaV1.InferInput<S>andInferOutput<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.
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
- Depend on the spec’s types, not a library.
@standard-schema/specprovides types only; your helpers have no runtime dependency on any validator. - Call
~standard.validateand normalise sync and async. Awaiting the result when it is a Promise lets one code path serve every schema. - 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. - Separate root issues from field issues. An issue with no path belongs to the form, not a field — route it to form-level messaging.
- Type inputs and outputs from the schema.
InferInputtypes what the form holds;InferOutputtypes what submit receives, keeping the distinction from wiring React Hook Form to a Zod resolver. - 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.
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.
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.