Standard Schema: Library-Agnostic Validation
How do you write form code — a validation helper, a design-system form component, a server action wrapper — that accepts a Zod schema today and a Valibot or ArkType schema tomorrow without changing? Standard Schema is a small, shared TypeScript interface that these libraries implement: every compliant schema exposes a ~standard property with a validate function returning either a value or a list of issues with paths and messages. This recipe writes a single adapter over that interface, normalises paths to input names, handles both synchronous and asynchronous results, and feeds the errors to setCustomValidity() and reportValidity() from the Constraint Validation API — so the choice of schema library becomes a one-line change.
When to Build Against Standard Schema
Use the interface wherever schema-consuming code is shared or long-lived:
- Design systems and form components that accept a
schemaprop and should not force a library on every product team. - Shared server helpers — an action wrapper or an API handler factory — that validate requests from many routes.
- Codebases mid-migration between libraries, where both must work at once, as in Yup vs Zod for form validation.
Individual forms that simply call one library’s safeParse gain little from indirection. The value is in code that consumes schemas written by others. The wider comparison of libraries is in alternative schema libraries.
Minimal Working Adapter
// The interface, published as @standard-schema/spec (types only — zero runtime cost).
import type { StandardSchemaV1 } from "@standard-schema/spec";
export type FieldErrors = Record<string, string>;
export type ValidationResult<T> = { ok: true; value: T } | { ok: false; errors: FieldErrors; formErrors: string[] };
/** Path segments can be keys or { key } objects; flatten to "items.0.qty". */
function toName(path: StandardSchemaV1.Issue["path"]): string {
return (path ?? []).map((seg) => (typeof seg === "object" && seg !== null ? String(seg.key) : String(seg))).join(".");
}
export async function validateWith<S extends StandardSchemaV1>(
schema: S,
input: unknown,
): Promise<ValidationResult<StandardSchemaV1.InferOutput<S>>> {
let result = schema["~standard"].validate(input);
if (result instanceof Promise) result = await result; // async schemas return a promise
if (!result.issues) return { ok: true, value: result.value };
const errors: FieldErrors = {};
const formErrors: string[] = [];
for (const issue of result.issues) {
const name = toName(issue.path);
if (!name) formErrors.push(issue.message); // root-level issue: not tied to a field
else errors[name] ??= issue.message; // first message per field
}
return { ok: false, errors, formErrors };
}
// Form binding: library-agnostic by construction.
export function bindForm<S extends StandardSchemaV1>(form: HTMLFormElement, schema: S, onValid: (value: StandardSchemaV1.InferOutput<S>) => void) {
const summary = form.querySelector<HTMLElement>("[data-form-errors]");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const result = await validateWith(schema, Object.fromEntries(new FormData(form)));
for (const el of form.querySelectorAll<HTMLInputElement>("[name]")) {
el.setCustomValidity(result.ok ? "" : result.errors[el.name] ?? "");
}
if (summary) {
summary.textContent = result.ok ? "" : result.formErrors.join(" ");
summary.hidden = result.ok || result.formErrors.length === 0;
}
if (form.reportValidity() && result.ok) onValid(result.value);
});
}
// The same binding with three different libraries — nothing else changes.
import { z } from "zod";
import * as v from "valibot";
import { type } from "arktype";
const zodSchema = z.object({ email: z.string().email("Enter an email address like name@example.com.") });
const valibotSchema = v.object({ email: v.pipe(v.string(), v.email("Enter an email address like name@example.com.")) });
const arkSchema = type({ email: "string.email" });
bindForm(document.querySelector("#a")!, zodSchema, save);
bindForm(document.querySelector("#b")!, valibotSchema, save);
bindForm(document.querySelector("#c")!, arkSchema, save);
The adapter never imports a library. It depends only on the interface’s types, which the @standard-schema/spec package provides with no runtime code at all. StandardSchemaV1.InferOutput<S> gives the parsed type for whichever library produced the schema, so onValid stays fully typed.
Standard Schema Interface Reference
| Member | Type | Meaning | Notes |
|---|---|---|---|
schema["~standard"] |
object | The interface namespace | Tilde prefix keeps it out of editor autocompletion |
.version |
1 |
Interface version | Check it if you support several versions |
.vendor |
string |
Library name, e.g. "zod" |
Useful for logging, not for branching |
.validate(value) |
(unknown) => Result | Promise<Result> |
Run validation | May be async; always handle both |
Result.value |
Output |
Parsed output on success | Present when issues is absent |
Result.issues |
ReadonlyArray<Issue> |
Failures | Each has message and optional path |
Issue.path |
ReadonlyArray<PropertyKey | { key }> |
Location | Segments may be objects with a key |
InferInput<S> / InferOutput<S> |
types | Input and output types | Library-independent inference |
Verification Steps
import { describe, it, expect } from "vitest";
import { z } from "zod";
import * as v from "valibot";
import { validateWith } from "./standard";
const MSG = "Enter an email address like name@example.com.";
const schemas = {
zod: z.object({ address: z.object({ email: z.string().email(MSG) }) }),
valibot: v.object({ address: v.object({ email: v.pipe(v.string(), v.email(MSG)) }) }),
};
describe.each(Object.entries(schemas))("%s via Standard Schema", (_, schema) => {
it("reports nested paths as dotted names", async () => {
expect(await validateWith(schema, { address: { email: "nope" } })).toEqual({ ok: false, errors: { "address.email": MSG }, formErrors: [] });
});
it("returns the parsed value on success", async () => {
expect(await validateWith(schema, { address: { email: "ada@example.com" } })).toMatchObject({ ok: true });
});
});
Edge Cases and Failure Modes
Forgetting the promise. Treating validate’s result as synchronous works for most schemas and breaks the first time someone adds an async refinement. Always check for a promise, as the adapter does.
Path segment objects. Some libraries emit path segments as { key: "email" } objects rather than plain strings. Normalise both forms; a join(".") on objects produces [object Object].
Library-specific features behind the interface. Standard Schema exposes validation, not schema construction or introspection. Code that needs to read a schema’s shape (to render fields automatically, say) still depends on a library. Keep that code separate from validation so the validation path stays portable.
Different default messages across libraries. Switching libraries changes any message you did not set explicitly. Set messages on every rule, or map issues through a message catalogue keyed by your own codes, as in keeping client and server error messages in sync.
Designing Components Around the Interface
The interface is most valuable in reusable form components. A design-system <Form> component that takes schema: StandardSchemaV1 as a prop can validate on submit, render errors beside each field and move focus with reportValidity(), without its authors ever choosing a schema library for the product teams that use it. Keep the component’s contract narrow: it accepts a schema, initial values and an onValid callback typed from InferOutput, and it exposes a way to inject server errors after submission. Everything a team might want to customise — messages, extra client-only rules, async hints — lives in the schema they pass in or in their own callbacks, never in a library-specific option on the component. That keeps the component’s public API stable across library upgrades and lets two teams using different libraries share it.
Wrapping a Library That Lacks Native Support
Libraries that predate the interface can still participate: wrap them in an object that exposes a ~standard property. The wrapper translates the library’s result into the interface’s shape, and from then on every consumer treats it like any other compliant schema. Yup is the common case, because many codebases still hold large numbers of Yup schemas.
import type { StandardSchemaV1 } from "@standard-schema/spec";
import type { AnySchema, ValidationError, InferType } from "yup";
export function fromYup<S extends AnySchema>(schema: S): StandardSchemaV1<unknown, InferType<S>> {
return {
"~standard": {
version: 1,
vendor: "yup",
async validate(value) {
try {
return { value: await schema.validate(value, { abortEarly: false }) };
} catch (err) {
const e = err as ValidationError;
return {
issues: e.inner.map((i) => ({
message: i.message,
path: (i.path ?? "").replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean),
})),
};
}
},
},
};
}
The wrapper converts Yup’s bracketed paths (items[0].qty) into segments, so the adapter’s toName produces the same dotted names as it does for Zod and Valibot. A form that used to call yupSchema.validate directly can now call validateWith(fromYup(yupSchema), data) and later switch to a Zod schema without further changes — a practical path for teams following migrating from Formik to React Hook Form.
Using the Adapter on the Server
The same adapter validates requests on the server, which lets a shared request-handling helper accept any team’s schema. A small wrapper parses FormData, validates through Standard Schema, and returns a 422 with field errors or calls the handler with the typed value — the pattern from validating FormData on the server with Zod, minus the dependency on Zod.
export function withSchema<S extends StandardSchemaV1>(
schema: S,
handler: (value: StandardSchemaV1.InferOutput<S>, request: Request) => Promise<Response>,
) {
return async (request: Request): Promise<Response> => {
const result = await validateWith(schema, Object.fromEntries(await request.formData()));
if (!result.ok) return Response.json({ errors: result.errors, formErrors: result.formErrors }, { status: 422 });
return handler(result.value, request);
};
}
Frequently Asked Questions
What is Standard Schema?
A small shared TypeScript interface that schema libraries implement. Each compliant schema exposes a ~standard property with a validate function that returns either a value or a list of issues with paths and messages.
Which libraries implement Standard Schema?
Zod (from 3.24), Valibot and ArkType, among others. Libraries without native support, such as Yup, can be wrapped with a small adapter object that exposes the same property.
Does Standard Schema add to my bundle?
No. The @standard-schema/spec package contains only TypeScript types. The runtime is whatever schema library you already use.
Is validate always synchronous?
No. It may return a promise when the schema contains async rules, so consumers should check for a promise and await it.
Related Guides
- Alternative Schema Libraries — the libraries behind the interface.
- Valibot Form Validation — a compliant library in practice.
- TanStack Form Validation — a form library that accepts Standard Schema directly.
- Sharing Zod Schemas in a Monorepo — packaging schemas for several consumers.