Standard Schema is a single TypeScript interface that Zod, Valibot, ArkType, Effect Schema and Yup all expose under a ~standard property. Any library that reads that property can validate with any of them, and infer the input and output types, without shipping a per-library adapter. One interface, every validator.
For an application team it delivers less than the headline suggests. The interface covers validation and type inference. It says almost nothing about error shapes, and that is where a swap from Zod to Valibot actually costs you an afternoon.
What is in the ~standard property
Four fields, and that is the whole surface:
interface Props<Input = unknown, Output = Input> {
readonly version: 1;
readonly vendor: string;
readonly types?: { input: Input; output: Output } | undefined;
readonly validate: (
value: unknown,
options?: StandardSchemaV1.Options | undefined,
) => Result<Output> | Promise<Result<Output>>;
}
validate returns { value } on success and { issues } on failure. It does not throw on a validation error, and it may or may not hand back a promise, so a consumer has to cope with both:
import type { StandardSchemaV1 } from '@standard-schema/spec';
export async function standardValidate<T extends StandardSchemaV1>(
schema: T,
input: unknown,
): Promise<StandardSchemaV1.InferOutput<T>> {
let result = schema['~standard'].validate(input);
if (result instanceof Promise) result = await result;
if (result.issues) {
throw new SchemaError(result.issues);
}
return result.value;
}
The instanceof Promise check is not fussiness. Awaiting unconditionally works, but it pushes every synchronous validation onto the microtask queue, which is a real cost on a request path that validates a body, a query string and a set of route params.
@standard-schema/spec sits at 1.1.0 and ships no runtime code, only types. Install it as a dev dependency, or paste the interface block into your own codebase, which the spec explicitly invites you to do. Nothing in your bundle changes either way.
StandardSchemaV1.InferInput and InferOutput do the type extraction, pulling from ~standard.types, which exists at the type level only. Do not read it at runtime; it is undefined there.
Which TypeScript libraries implement Standard Schema
standardschema.dev lists Zod from 3.24.0, Valibot from 1.0, ArkType from 2.0, Effect Schema from 3.13.0, Yup from 1.7.0, Joi from 18.0.0 and Mongoose from 9.7.0. On npm as of 27 August 2026 those first three are zod 4.4.3, valibot 1.4.2 and arktype 2.2.3.
The consuming side is the reason to care.
tRPC 11.18.0 takes a Standard Schema as procedure input and exports a StandardSchemaV1Error carrying the issue array. TanStack Form 1.33.5 types its field validators as a validate function or a StandardSchemaV1, so a schema drops straight in with no resolver in between. @hookform/resolvers 5.9.1 added a ./standard-schema subpath exporting standardSchemaResolver, sitting next to its per-library resolvers for Yup, Joi, io-ts and a dozen others. @hono/standard-validator 0.4.0 does the same job as Hono middleware.
That list is the actual win. Every one of those integrations used to be a separate adapter package tracking a separate library’s releases.
Why issue.path.join('.') returns [object Object]
Here is the part that bites. The spec types an issue path as:
readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined;
Two permitted shapes, and libraries chose differently. Zod emits plain keys. Valibot emits objects that carry key alongside its own context (type, origin, input, value). Same schema, same bad input, same interface:
zod [ "user", "email" ]
valibot [ { "type": "object", "origin": "value", "key": "user" },
{ "type": "object", "origin": "value", "key": "email" } ]
So issue.path.join('.') gives you user.email under Zod and [object Object].[object Object] under Valibot. Nothing throws. It reaches production as a form field that never highlights, or a log line nobody can trace.
Normalise once, at the boundary:
function issuePath(issue: StandardSchemaV1.Issue): string {
return (issue.path ?? [])
.map((segment) => (typeof segment === 'object' ? segment.key : segment))
.reduce<string>(
(acc, key) =>
typeof key === 'number'
? `${acc}[${key}]`
: acc
? `${acc}.${String(key)}`
: String(key),
'',
);
}
If Zod is already in the project you can skip writing that. z.core.toDotPath() accepts a path array and z.prettifyError() accepts a StandardSchemaV1.FailureResult, both regardless of which library produced it. Handing Valibot’s failure result to Zod’s prettifyError prints a formatted report with at user.email and at tags[0] under each message.
What does not survive a swap between validators
Issues carry message and path in the spec. Everything else is vendor territory, and the vendors disagree.
Zod’s issues expose code, origin, format and pattern. Valibot passes through its native issue object, with kind, type, expected, received and requirement, and no code field anywhere. Any branch written on issue.code is Zod-specific code wearing a generic type, and TypeScript will not warn you, because code is not in the interface for it to remove.
Messages differ as well. Zod produces Invalid input: expected string, received number. Valibot produces Invalid type: Expected string but received 1. If those strings reach a user, or a test asserts on them, a library swap is a copy change too.
libraryOptions arrived in spec 1.1.0 as the escape hatch for vendor-specific validation options. Valibot 1.4.2 still declares validate with a single parameter, so an options object handed to it is dropped in silence. Treat the field as advisory rather than a contract.
Then the obvious one. None of your .refine(), .transform(), .pipe(), coercion or codec code is portable. The interface is a handshake between a schema and a tool, not a portable schema language.
Does Standard Schema give you JSON Schema
Not the original interface. A companion spec, StandardJSONSchemaV1, adds it as a converter rather than a document:
readonly jsonSchema: {
readonly input: (options: { target: Target }) => Record<string, unknown>;
readonly output: (options: { target: Target }) => Record<string, unknown>;
};
target is "draft-2020-12", "draft-07", "openapi-3.0" or any other string, and a library is expected to throw when it cannot produce what you asked for. Splitting input from output matters for any schema with a transform in it, because the two sides stop matching the moment you coerce a string into a Date.
Support is uneven as of August 2026. Zod 4.4.3 implements it, so schema['~standard'].jsonSchema.input({ target: 'draft-07' }) returns a draft-07 document with $schema set. Valibot 1.4.2 does not; its converter lives in the separate @valibot/to-json-schema package. Feature-detect before calling:
const props = schema['~standard'];
if ('jsonSchema' in props) {
const document = props.jsonSchema.input({ target: 'draft-2020-12' });
}
That converter is what makes generic OpenAPI generation and LLM tool definitions possible without a switch statement over vendor names.
When to build on it, and when to leave it alone
Write against StandardSchemaV1 if you publish anything that accepts a user-supplied schema: a route-handler factory, an internal RPC layer, a config loader, a queue-message contract. You lose a peer dependency, you stop maintaining resolvers, and the inference comes free. It is the default in the API layers we build, because the validation library is the choice a client team most often wants to revisit two years later.
Leave it alone inside a single application that uses one validator and has no plan to change. Zod’s own API is far richer than the interface, z.treeifyError and z.discriminatedUnion included, and routing through ~standard there buys indirection and nothing else. The same reasoning we used when weighing oxlint against Biome applies: interoperability is worth paying for at a boundary, not in the middle of your own code.
If you are mid-swap already, budget the time for the error layer rather than the schemas themselves. The schemas rewrite mechanically. The path shapes, the codes and the message strings are what break quietly.