Skip to main content

Standard Schema: TypeScript Validation Interop 2026

·PkgPulse Team
0

You chose Zod for your validation. Your form library supports it. Your tRPC router validates with it. Six months later you want to switch to Valibot for its smaller bundle — and you can't, because Valibot schemas aren't Zod schemas. You'd need to rewrite every form, every route validator, every schema. That's the validation lock-in problem. Standard Schema solves it.

TL;DR

Standard Schema (@standard-schema/spec) is a TypeScript interface that validation libraries implement, allowing any tool that accepts "a schema" to work with Zod, Valibot, ArkType, or any other conforming library interchangeably. It's ~60 lines of types. It costs zero runtime bytes. tRPC v11, TanStack Form v1, TanStack Router, and Conform already ship with Standard Schema support. Switch validation libraries without rewriting your app.

Key Takeaways

  • Standard Schema is a spec, not a library — it's a TypeScript interface that validation libraries implement via a ~standard property
  • Zero runtime cost — the entire spec is types-only; it compiles away to nothing
  • Adopted by major ecosystem tools — tRPC, TanStack Form, TanStack Router, Conform, and next.js (under discussion in 2026)
  • Zod v3.23+, Valibot v0.31+, ArkType v2+ all implement the spec natively
  • The ~standard naming is intentional — the tilde prefix sorts to the bottom of autocomplete lists, keeping it out of your way

The Validation Lock-In Problem

Before Standard Schema, every form library or router that wanted to support validation had to add explicit support for each validation library:

// Before Standard Schema — TanStack Form had to maintain separate adapters:
import { zodValidator } from '@tanstack/zod-form-adapter'
import { valibotValidator } from '@tanstack/valibot-form-adapter'
import { arktypeValidator } from '@tanstack/arktype-form-adapter'

// Different import for every library
const form = useForm({ validatorAdapter: zodValidator() })

Library authors maintained adapters for each validator. When Valibot v1.0 shipped, every adapter needed updating. When ArkType v2 released a breaking change, every adapter broke.

The problem compounds for end users: picking a validation library became an architectural decision that was hard to reverse. If TanStack Form shipped its Valibot adapter 3 weeks after the Zod adapter, you were stuck using Zod until it landed.


What Standard Schema Actually Is

The full spec lives at @standard-schema/spec on npm and is licensed MIT. The core interface:

// The full Standard Schema interface (simplified)
export interface StandardSchemaV1<Input = unknown, Output = Input> {
  readonly '~standard': {
    readonly version: 1;
    readonly vendor: string; // e.g., "zod", "valibot", "arktype"
    readonly validate: (value: unknown) => StandardSchemaV1.Result<Output> | Promise<StandardSchemaV1.Result<Output>>;
    readonly types?: StandardSchemaV1.InferTypes<Input, Output>;
  };
}

export namespace StandardSchemaV1 {
  export type Result<Output> =
    | { readonly value: Output; readonly issues?: undefined }
    | { readonly issues: ReadonlyArray<Issue> };

  export type Issue = {
    readonly message: string;
    readonly path?: ReadonlyArray<string | number | symbol>;
  };
}

The tilde (~) prefix is intentional. It sorts to the bottom of TypeScript's autocomplete, so schema.~standard doesn't pollute your intellisense when you're working with schema.parse() or schema.safeParse().

How Libraries Implement It

Here's how Valibot exposes Standard Schema compliance on its schemas:

import * as v from 'valibot';

const EmailSchema = v.pipe(v.string(), v.email());

// Every Valibot schema automatically has ~standard
console.log(EmailSchema['~standard'].vendor); // "valibot"
console.log(EmailSchema['~standard'].version); // 1

// Validates using the spec's interface
const result = await EmailSchema['~standard'].validate('not-an-email');
// { issues: [{ message: 'Invalid email', path: [] }] }

You never call ~standard.validate directly — framework libraries do. As a developer, you just pass your schema:

import * as v from 'valibot';
import { useForm } from '@tanstack/react-form';

const form = useForm({
  defaultValues: { email: '', name: '' },
  validators: {
    onChange: v.object({   // ← plain Valibot schema, no adapter needed
      email: v.pipe(v.string(), v.email()),
      name: v.pipe(v.string(), v.minLength(2)),
    }),
  },
});

Compare that to the old adapter pattern:

// Old way — required @tanstack/valibot-form-adapter
import { valibotValidator } from '@tanstack/valibot-form-adapter'
const form = useForm({ validatorAdapter: valibotValidator(), ... })

Library Support Matrix (March 2026)

Validation Libraries

LibraryStandard SchemaVersion AddedNotes
Zodv3.23.0Full support, sync only
Valibotv0.31.0Full support, async support
ArkTypev2.0.0Full support, embedded validators
TypeBoxCommunity implVia @sinclair/typebox
Effect Schemav3.10+Full support
SuperstructPlannedIssue open
YupNot plannedUnmaintained

Consumer Libraries (tools that accept schemas)

ToolStandard SchemaVersion
tRPCv11.0
TanStack Formv1.0
TanStack Routerv1.20+
Conformv1.1+
Honov4.3+
ElysiaJSv1.1+
Next.js Server ActionsDiscussion #75086

The ArkType 2.2 Angle: Embedded Validators

ArkType 2.2 added something no other Standard Schema library has done yet: you can embed Zod or Valibot schemas inside ArkType definitions, and they'll validate correctly.

import { type } from 'arktype';
import { z } from 'zod';

// Mix ArkType and Zod in one schema
const UserSchema = type({
  id: 'string.uuid',
  email: type.unit(z.string().email()), // Zod schema embedded in ArkType
  age: 'number > 0',
});

// Validates with ArkType's runtime, but delegates email to Zod
const result = UserSchema({ id: 'abc', email: 'not-an-email', age: 25 });

This works because Standard Schema provides a common validation interface. ArkType can call schema['~standard'].validate() on any conforming schema without knowing which library produced it.


Migrating from Zod to Valibot with Standard Schema

Standard Schema doesn't automatically migrate your schemas — you still need to rewrite your schema definitions to use the new library's syntax. What it does is eliminate the framework-level migration work:

Before Standard Schema (migrating Zod → Valibot in tRPC):

  1. Replace all z.* calls with v.* equivalents
  2. Update trpc.router({ input: zodSchema }) to use Valibot adapter
  3. Update all form library adapters
  4. Update test utilities

After Standard Schema (migrating Zod → Valibot in tRPC):

  1. Replace all z.* calls with v.* equivalents
  2. Update tRPC — no change needed, tRPC accepts any Standard Schema-compatible schema
  3. Update form adapters — no change needed
  4. Update test utilities

The framework layer becomes library-agnostic. You only rewrite the schema definitions themselves, not all the plumbing that uses them.


Bundle Size Impact

Standard Schema is one of the most compelling reasons to use Valibot over Zod if bundle size matters:

LibraryBundle Size (gzip)Tree-shakeable
Zod~14KBPartial
Valibot~1.2KB (per-function)Yes
ArkType~17KBPartial
Effect Schema~29KBYes

With Standard Schema, you can build your app's form layer and API routers against the Standard Schema interface, use Zod during development (for its great error messages), and swap to Valibot for production bundles — without touching your tRPC or TanStack Form code.


How to Use Standard Schema in Your Own Library

If you're building a form library, router, or any tool that accepts user-provided schemas:

import type { StandardSchemaV1 } from '@standard-schema/spec';

async function validateInput<T>(
  schema: StandardSchemaV1<unknown, T>,
  input: unknown
): Promise<{ data: T } | { errors: StandardSchemaV1.Issue[] }> {
  const result = await schema['~standard'].validate(input);

  if (result.issues) {
    return { errors: result.issues };
  }

  return { data: result.value };
}

// Works with any conforming library
import { z } from 'zod';
import * as v from 'valibot';

const emailZod = z.string().email();
const emailValibot = v.pipe(v.string(), v.email());

// Both work identically through your library
await validateInput(emailZod, 'test@example.com');   // { data: 'test@example.com' }
await validateInput(emailValibot, 'not-an-email');    // { errors: [...] }

Adding @standard-schema/spec as a peer dependency (it's types-only) and accepting StandardSchemaV1 in your public API means your library works with any compliant validator — today and in the future.


Practical Workflow: Testing Standard Schema Interop

One underappreciated benefit is that Standard Schema makes testing validation logic easier. You can write a single test helper that works with any library:

// test-utils/validate.ts
import type { StandardSchemaV1 } from '@standard-schema/spec';

export async function expectValid<T>(
  schema: StandardSchemaV1<unknown, T>,
  value: unknown
): Promise<T> {
  const result = await schema['~standard'].validate(value);
  if (result.issues) {
    throw new Error(
      `Expected valid, got issues: ${result.issues.map(i => i.message).join(', ')}`
    );
  }
  return result.value;
}

export async function expectInvalid(
  schema: StandardSchemaV1,
  value: unknown
): Promise<StandardSchemaV1.Issue[]> {
  const result = await schema['~standard'].validate(value);
  if (!result.issues) {
    throw new Error(`Expected invalid, got value: ${JSON.stringify(result.value)}`);
  }
  return result.issues;
}

Now your tests become library-agnostic:

import { z } from 'zod';
import * as v from 'valibot';
import { type } from 'arktype';
import { expectValid, expectInvalid } from './test-utils/validate';

// Test your application logic with any schema library
const EmailZod = z.string().email();
const EmailValibot = v.pipe(v.string(), v.email());
const EmailArkType = type('string.email');

// All three pass the same tests
for (const schema of [EmailZod, EmailValibot, EmailArkType]) {
  await expectValid(schema, 'user@example.com');
  await expectInvalid(schema, 'not-an-email');
}

This pattern is valuable if you're maintaining a library that accepts user-provided schemas. You can write your own test suite that validates your integration against all three major libraries with a single test body.

Standard Schema in Error Handling Middleware

Standard Schema also normalizes error handling across libraries — a non-obvious benefit:

// Generic error formatter for any Standard Schema library
function formatValidationErrors(issues: StandardSchemaV1.Issue[]): Record<string, string> {
  return Object.fromEntries(
    issues.map(issue => [
      issue.path?.join('.') ?? '_root',
      issue.message,
    ])
  );
}

// Works identically for Zod, Valibot, or ArkType validation errors
app.use((err, req, res, next) => {
  if (Array.isArray(err.issues)) {
    // Standard Schema issue format
    return res.status(422).json({
      errors: formatValidationErrors(err.issues),
    });
  }
  next(err);
});

Before Standard Schema, this middleware would need separate branches for err.errors (Zod), err.issues (Valibot), and err.summary (ArkType). Now any library that conforms to the spec produces errors in the same shape.


What Standard Schema Doesn't Solve

Standard Schema is intentionally minimal. It doesn't standardize:

  • Schema composition — how schemas are combined (z.merge() vs v.merge()) remains library-specific
  • Error message formatting — each library formats its error messages differently
  • Schema introspection — reading the shape of a schema at runtime (for docs generation, OpenAPI output) is still library-specific
  • Async validation — the spec supports it, but library behavior varies (some buffer async results, some stream them)

For interoperability across all these dimensions, you still need the specific library or separate tooling.


Methodology


Also see our Zod v4 vs Valibot vs ArkType comparison for a full feature comparison. Standard Schema is most useful when paired with TanStack Form or tRPC.

Comments

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.