Skip to main content
Standard Schema is the secret behind Semola’s flexibility. It’s a universal interface that lets you use any validation library—Zod, Valibot, ArkType, or others—without vendor lock-in.

What is Standard Schema?

Standard Schema is a specification that defines a common interface for schema validation libraries. Instead of tightly coupling to a specific validator, Semola depends on the Standard Schema interface. Think of it like this:
  • USB-C is a standard interface for charging and data transfer
  • Standard Schema is a standard interface for data validation
Just as you can plug any USB-C device into any USB-C port, you can use any Standard Schema validator with Semola.

How Semola uses Standard Schema

Semola imports the Standard Schema types from @standard-schema/spec:
src/lib/api/core/types.ts
import type { StandardSchemaV1 } from "@standard-schema/spec";

export type RequestSchema = {
  params?: StandardSchemaV1;
  body?: StandardSchemaV1;
  query?: StandardSchemaV1;
  headers?: StandardSchemaV1;
  cookies?: StandardSchemaV1;
};

export type ResponseSchema = {
  [status: number]: StandardSchemaV1;
};
@standard-schema/spec is a type-only package. It has zero runtime code and adds 0 bytes to your bundle.

Validation with Standard Schema

Semola validates data by calling the standard ~standard.validate method:
src/lib/api/validation/index.ts
import type { StandardSchemaV1 } from "@standard-schema/spec";
import { err, ok } from "../../errors/index.js";

export const validateSchema = async <T>(
  schema: StandardSchemaV1,
  data: unknown,
) => {
  // Call the standard validation method
  const result = await schema["~standard"].validate(data);

  // No issues = success
  if (!result.issues) {
    return ok(result.value as T);
  }

  // Format issues into error message
  const issues = result.issues.map((issue) => {
    let path = "unknown";
    if (Array.isArray(issue.path)) {
      path = issue.path.map(String).join(".");
    }
    return `${path}: ${issue.message ?? "validation failed"}`;
  });

  return err("ValidationError", issues.join(", "));
};
This works with any validator that implements the Standard Schema interface.

Supported validators

Zod

Zod is the most popular TypeScript validation library:
import { Api } from 'semola/api';
import { z } from 'zod';

const api = new Api();

api.defineRoute({
  path: '/users',
  method: 'POST',
  request: {
    body: z.object({
      name: z.string().min(1),
      email: z.string().email(),
      age: z.number().int().positive(),
    }),
  },
  response: {
    201: z.object({ id: z.string(), name: z.string() }),
  },
  handler: async (ctx) => {
    const user = await db.createUser(ctx.req.body);
    return ctx.json(201, user);
  },
});

Valibot

Valibot is a smaller, faster alternative to Zod:
import { Api } from 'semola/api';
import * as v from 'valibot';

const api = new Api();

api.defineRoute({
  path: '/users',
  method: 'POST',
  request: {
    body: v.object({
      name: v.pipe(v.string(), v.minLength(1)),
      email: v.pipe(v.string(), v.email()),
      age: v.pipe(v.number(), v.integer(), v.minValue(1)),
    }),
  },
  response: {
    201: v.object({ id: v.string(), name: v.string() }),
  },
  handler: async (ctx) => {
    const user = await db.createUser(ctx.req.body);
    return ctx.json(201, user);
  },
});

ArkType

ArkType offers runtime type validation with TypeScript-like syntax:
import { Api } from 'semola/api';
import { type } from 'arktype';

const api = new Api();

api.defineRoute({
  path: '/users',
  method: 'POST',
  request: {
    body: type({
      name: 'string>0',
      email: 'string.email',
      'age?': 'number.integer>0',
    }),
  },
  response: {
    201: type({ id: 'string', name: 'string' }),
  },
  handler: async (ctx) => {
    const user = await db.createUser(ctx.req.body);
    return ctx.json(201, user);
  },
});

Why framework-agnostic validation matters

1. No vendor lock-in

Switch validators without rewriting your routes:
// Before: Using Zod
import { z } from 'zod';
const schema = z.object({ name: z.string() });

// After: Using Valibot
import * as v from 'valibot';
const schema = v.object({ name: v.string() });

// Your route handlers don't change

2. Use the best tool for the job

  • Zod: Rich ecosystem, excellent TypeScript integration
  • Valibot: Smaller bundle size, tree-shakeable, faster validation
  • ArkType: Fastest runtime performance, terse syntax
Pick the validator that matches your priorities.

3. Mix and match

Use different validators in different parts of your app:
import { z } from 'zod';
import * as v from 'valibot';

// Use Zod for complex schemas
api.defineRoute({
  path: '/orders',
  method: 'POST',
  request: {
    body: z.object({
      items: z.array(z.object({ id: z.string(), quantity: z.number() })),
      shipping: z.object({ address: z.string(), city: z.string() }),
    }),
  },
  handler: async (ctx) => { /* ... */ },
});

// Use Valibot for simple schemas (smaller bundle)
api.defineRoute({
  path: '/ping',
  method: 'GET',
  response: {
    200: v.object({ status: v.literal('ok') }),
  },
  handler: async (ctx) => ctx.json(200, { status: 'ok' }),
});

4. Future-proof

When new validators emerge, they’ll work with Semola as long as they implement Standard Schema. You’re not locked into today’s choices.

Type inference across validators

Semola’s type inference works regardless of which validator you use:
src/lib/api/core/types.ts
type SafeTypeAccess<
  T,
  K extends "input" | "output",
> = T extends StandardSchemaV1
  ? T["~standard"] extends { types?: infer U }
    ? U extends Record<K, infer V>
      ? V
      : never
    : never
  : undefined;

export type InferOutput<T extends StandardSchemaV1 | undefined> =
  SafeTypeAccess<T, "output">;
This extracts types from the ~standard.types property that all Standard Schema validators provide.

Example: Type inference with Zod

import { z } from 'zod';

const userSchema = z.object({
  id: z.string().uuid(),
  name: z.string(),
  age: z.number().optional(),
});

api.defineRoute({
  path: '/users/:id',
  method: 'PATCH',
  request: { body: userSchema },
  handler: async (ctx) => {
    // TypeScript infers:
    // ctx.req.body is { id: string; name: string; age?: number }
    const updated = await db.updateUser(ctx.req.body);
    return ctx.json(200, updated);
  },
});

Example: Type inference with Valibot

import * as v from 'valibot';

const userSchema = v.object({
  id: v.pipe(v.string(), v.uuid()),
  name: v.string(),
  age: v.optional(v.number()),
});

api.defineRoute({
  path: '/users/:id',
  method: 'PATCH',
  request: { body: userSchema },
  handler: async (ctx) => {
    // TypeScript infers the same type:
    // ctx.req.body is { id: string; name: string; age?: number }
    const updated = await db.updateUser(ctx.req.body);
    return ctx.json(200, updated);
  },
});
The types are identical because both validators implement the same Standard Schema interface.

OpenAPI generation

Standard Schema doesn’t just help with validation—it also enables OpenAPI spec generation. Semola extracts schema metadata to generate accurate API documentation:
const api = new Api({
  openapi: {
    title: 'My API',
    version: '1.0.0',
  },
});

api.defineRoute({
  path: '/users',
  method: 'POST',
  request: {
    body: z.object({
      name: z.string().describe('User full name'),
      email: z.string().email().describe('User email address'),
    }),
  },
  response: {
    201: z.object({ id: z.string(), name: z.string() }),
  },
  summary: 'Create a new user',
  handler: async (ctx) => { /* ... */ },
});

// Generate OpenAPI spec
const spec = api.getOpenApiSpec();
This works with any Standard Schema validator that supports schema introspection.

The Standard Schema interface

Here’s what Standard Schema validators implement (simplified):
interface StandardSchemaV1 {
  readonly "~standard": {
    readonly version: 1;
    readonly vendor: string;
    readonly validate: (data: unknown) => Result;
    readonly types?: {
      input: unknown;
      output: unknown;
    };
  };
}

type Result =
  | { value: unknown; issues?: undefined }
  | { issues: Issue[] };

type Issue = {
  message?: string;
  path?: PropertyKey[];
};
This simple interface enables interoperability across the entire TypeScript validation ecosystem.

Adding Standard Schema support

If you’re building a validator, adding Standard Schema support is straightforward. Check the Standard Schema specification for details. Once you implement the interface, your validator works with Semola and any other Standard Schema-compatible framework.

Performance considerations

Standard Schema adds minimal overhead:
  1. No adapter layer: Semola calls schema["~standard"].validate() directly
  2. No double parsing: Validation happens once
  3. Type-only imports: The spec package adds 0 runtime bytes
Performance differences come from the validator itself, not the interface.

Benchmark: Zod vs Valibot vs ArkType

ValidatorOperations/secBundle size
ArkType~10M~15KB
Valibot~8M~12KB
Zod~5M~50KB
All three work identically with Semola. Choose based on your priorities.
Start with Zod for its excellent developer experience. Switch to Valibot or ArkType later if you need smaller bundles or faster validation.

Real-world example

Here’s a complete API using Standard Schema with multiple validators:
import { Api } from 'semola/api';
import { z } from 'zod';
import * as v from 'valibot';

const api = new Api({
  openapi: {
    title: 'Mixed Validator API',
    version: '1.0.0',
  },
});

// Use Zod for complex user schema
api.defineRoute({
  path: '/users',
  method: 'POST',
  request: {
    body: z.object({
      name: z.string().min(1).max(100),
      email: z.string().email(),
      preferences: z.object({
        theme: z.enum(['light', 'dark']),
        notifications: z.boolean(),
      }).optional(),
    }),
  },
  response: {
    201: z.object({ id: z.string(), name: z.string() }),
  },
  handler: async (ctx) => {
    const user = await db.createUser(ctx.req.body);
    return ctx.json(201, user);
  },
});

// Use Valibot for simple health check
api.defineRoute({
  path: '/health',
  method: 'GET',
  response: {
    200: v.object({ status: v.literal('healthy') }),
  },
  handler: async (ctx) => {
    return ctx.json(200, { status: 'healthy' });
  },
});

api.serve(3000);
Both routes work seamlessly together, each using the best validator for their needs.

Learn more

Build docs developers (and LLMs) love