← Back to Blog
TypeScript2026-05-10·68 min read

How to convert a JSON sample to a Valibot schema (and the 3 ways the algorithm diverges from Zod)

By JSON to TS

Building a JSON-to-Valibot Schema Emitter: Algorithmic Divergences and Bundle Optimization

Current Situation Analysis

Runtime validation boundaries in TypeScript require precise schema definitions to guarantee data integrity at API edges, message queues, and configuration loaders. Writing these schemas manually for complex, nested payloads is repetitive, drift-prone, and consumes disproportionate development time. Teams typically reach for Zod because of its fluent, chainable API and widespread ecosystem familiarity. However, this convenience introduces a hidden architectural cost: Zod’s method-chaining design creates a tightly coupled schema instance that bundlers cannot safely prune.

This problem is frequently overlooked because developers evaluate validation libraries based on developer experience rather than runtime footprint. The chainable API feels intuitive during development, but it forces the bundler to retain the entire validation class hierarchy, even when only a handful of primitives are used. Valibot addresses this by treating schemas as immutable values composed through pure functions. Every combinator (v.string, v.object, v.union) is a standalone export that dead-code elimination can drop if unused. The trade-off is algorithmic: automated schema generation must account for wrapping semantics, namespace imports, and function-only composition.

Data from production bundle audits consistently shows the impact. A medium-scale application with approximately 30 validation schemas across 8 endpoints ships roughly 14KB of minified Zod code. Switching to Valibot’s per-primitive tree-shaking model reduces this to 2–3KB. The reduction scales linearly with schema complexity because unused combinators are never included in the final artifact. The challenge lies in translating a JSON structure into Valibot’s composition model without introducing runtime errors or breaking TypeScript’s declaration hoisting rules.

WOW Moment: Key Findings

The shift from Zod to Valibot is not merely a syntax swap; it requires rethinking how schemas are composed, imported, and ordered. The following comparison highlights the architectural divergence:

Approach Bundle Footprint (30 schemas) Composition Model Tree-Shaking Efficiency
Zod (Chainable) ~14KB minified Method chaining on schema instances Low (monolithic class retention)
Valibot (Functional) ~2–3KB minified Pure function composition High (per-primitive dead-code elimination)
Manual TypeScript ~0KB (no runtime lib) Type guards / typeof N/A (no validation library)

This finding matters because it decouples validation logic from bundle overhead. By adopting Valibot’s functional composition model, teams can generate schemas from JSON samples while guaranteeing that only the exact combinators used in the application are shipped to production. The algorithmic divergences—wrapping semantics, namespace imports, and function-only unions—become the foundation for a reliable, automated emitter.

Core Solution

Building a JSON-to-Valibot emitter requires translating a JSON abstract syntax tree (AST) into Valibot schema strings while respecting three non-negotiable architectural rules. The emitter walks the JSON structure, assigns unique names to nested objects, orders declarations children-first, and applies Valibot-specific composition patterns.

Step 1: Define the Emitter Interface

The emitter accepts a JSON object and returns a string containing valid Valibot schema code. It maintains a registry of generated schemas to handle recursion and naming collisions.

interface EmitterContext {
  schemaRegistry: Map<string, string>;
  nameCounter: number;
}

function generateValibotSchema(json: unknown, context: EmitterContext): string {
  if (json === null) return "v.null()";
  if (typeof json === "boolean") return "v.boolean()";
  if (typeof json === "number") return "v.number()";
  if (typeof json === "string") return "v.string()";
  if (Array.isArray(json)) return handleArray(json, context);
  if (typeof json === "object") return handleObject(json as Record<string, unknown>, context);
  return "v.unknown()";
}

Step 2: Handle Arrays with Union Collapsing

Arrays require inspecting every element. If all elements share the same schema, emit a single v.array(). If types differ, collect unique schemas and wrap them in v.union(). Empty arrays default to v.unknown() to avoid rejecting valid payloads.

function handleArray(items: unknown[], context: EmitterContext): string {
  if (items.length === 0) return "v.unknown()";
  
  const childSchemas = items.map(item => generateValibotSchema(item, context));
  const uniqueSchemas = [...new Set(childSchemas)];
  
  if (uniqueSchemas.length === 1) {
    return `v.array(${uniqueSchemas[0]})`;
  }
  
  return `v.union([${uniqueSchemas.join(", ")}])`;
}

Step 3: Handle Objects with Children-First Ordering

Objects require assigning a unique schema name, generating child schemas, and registering the object schema. Crucially, child schemas must be emitted before the parent schema to satisfy JavaScript’s const hoisting constraints.

function handleObject(obj: Record<string, unknown>, context: EmitterContext): string {
  const schemaName = `Schema_${++context.nameCounter}`;
  const fieldEntries: string[] = [];
  
  for (const [key, value] of Object.entries(obj)) {
    const childSchema = generateValibotSchema(value, context);
    fieldEntries.push(`${key}: ${childSchema}`);
  }
  
  const schemaBody = `v.object({\n  ${fieldEntries.join(",\n  ")}\n})`;
  context.schemaRegistry.set(schemaName, schemaBody);
  return schemaName;
}

Step 4: Apply Valibot-Specific Composition Rules

The emitter must enforce three divergences from Zod’s model:

  1. Wrapping Semantics: Optional fields are wrapped using v.optional(), not chained. The emitter checks a metadata flag (e.g., from multi-sample analysis) and wraps the generated schema string.

    function applyModifiers(baseSchema: string, isOptional: boolean): string {
      return isOptional ? `v.optional(${baseSchema})` : baseSchema;
    }
    
  2. Namespace Imports: The output header must use import * as v from "valibot" to guarantee tree-shaking. Destructured imports prevent bundlers from dropping unused combinators.

  3. Function-Only Composition: Mixed types collapse exclusively into v.union([...]). There is no .or() method. The emitter treats unions as the sole composition path for heterogeneous data.

Step 5: Assemble Final Output

The emitter flattens the registry into a string, prepends the namespace import, and ensures declaration order matches dependency resolution.

function assembleOutput(context: EmitterContext): string {
  const declarations = Array.from(context.schemaRegistry.entries())
    .map(([name, body]) => `const ${name} = ${body};`)
    .join("\n\n");
    
  return `import * as v from "valibot";\n\n${declarations}`;
}

Architecture Decisions and Rationale

  • Children-First Ordering: JavaScript const declarations do not hoist. Referencing a schema before its declaration throws a ReferenceError. The emitter resolves dependencies by registering child schemas first, then parent schemas.
  • Namespace Import Enforcement: Valibot’s tree-shaking relies on static analysis of namespace properties. Bundlers like esbuild and Vite reliably drop unused v.* references when imported via import * as v. Destructured imports often retain the entire module in older Webpack pipelines.
  • Wrapping Over Chaining: Valibot schemas are immutable values, not objects with methods. Wrapping v.optional() around an inner schema preserves referential transparency and enables predictable composition with v.nullable() or v.pipe().
  • Union Collapsing: Generating v.union([v.string()]) is degenerate. The emitter collapses single-element unions to the bare schema, reducing runtime overhead and improving readability.

Pitfall Guide

Pitfall Explanation Fix
Assuming Method Chaining Works Writing v.string().optional() throws a runtime error because Valibot schemas lack prototype methods. Always wrap: v.optional(v.string()). Treat combinators as pure functions.
Destructuring Imports import { string, object } from "valibot" breaks tree-shaking in many bundlers, retaining unused code. Use import * as v from "valibot" and reference v.string(), v.object().
Ignoring Declaration Order Referencing a child schema before it is declared causes ReferenceError: Cannot access 'X' before initialization. Emit children-first. Register nested schemas before their parents.
Treating Empty Arrays as v.never() v.never() rejects all values. An empty JSON array represents an unknown shape, not an impossible one. Default empty arrays to v.unknown() to match TypeScript’s unknown[] semantics.
Over-Nesting Unions Generating v.union([v.string(), v.string()]) adds unnecessary runtime checks. Deduplicate schemas before union creation. Collapse single-element unions to the base type.
Confusing Optional and Nullable v.optional() handles missing keys. v.null() handles explicit null values. They are distinct. Use v.optional(v.union([v.string(), v.null()])) for fields that may be absent or null.
Skipping Pipe Composition Relying solely on base types misses validation opportunities (e.g., email format, length constraints). Wrap base types in v.pipe(v.string(), v.email(), v.minLength(5)) for refined validation.

Production Bundle

Action Checklist

  • Audit existing validation boundaries: Identify schemas with high runtime overhead or bundle impact.
  • Configure bundler for tree-shaking: Ensure esbuild/Vite/Webpack is set to mode: "production" and sideEffects: false.
  • Replace destructured imports: Migrate all import { ... } from "valibot" to import * as v from "valibot".
  • Implement children-first emitter: Build or integrate a JSON-to-Valibot generator that respects declaration ordering.
  • Add union deduplication: Prevent degenerate unions by collapsing identical child schemas before emission.
  • Apply pipe refinements: Replace bare v.string() with v.pipe(v.string(), v.email()) where domain knowledge exists.
  • Validate multi-sample inputs: Track missing keys across payloads to correctly apply v.optional() wrappers.
  • Benchmark bundle size: Compare pre- and post-migration artifacts using rollup-plugin-visualizer or source-map-explorer.

Decision Matrix

Scenario Recommended Approach Why Cost Impact
Public-facing web app with strict bundle limits Valibot + namespace imports Per-primitive tree-shaking reduces payload by ~80% Lower bandwidth, faster TTI
Internal microservice with Node.js runtime Zod or Valibot (neutral) Tree-shaking irrelevant in server environments No significant cost difference
Legacy codebase with heavy method chaining Zod (stay) Migration cost outweighs bundle savings High refactoring overhead
Automated schema generation from OpenAPI/JSON Valibot emitter Functional composition maps cleanly to AST walkers Faster development cycle
Mobile-first app with strict memory limits Valibot + pipe refinements Minimal runtime footprint, precise validation Lower memory usage, better UX

Configuration Template

// valibot-emitter.ts
import * as v from "valibot";

// Base schema registry for runtime validation
export const ValidationRegistry = {
  OrderItem: v.object({
    sku: v.string(),
    quantity: v.number(),
    price: v.pipe(v.number(), v.minValue(0)),
  }),
  
  ShippingAddress: v.object({
    street: v.string(),
    city: v.string(),
    postalCode: v.pipe(v.string(), v.minLength(5)),
    country: v.string(),
  }),
  
  OrderPayload: v.object({
    id: v.string(),
    status: v.union([v.literal("pending"), v.literal("shipped"), v.literal("delivered")]),
    items: v.array(ValidationRegistry.OrderItem),
    shipping: ValidationRegistry.ShippingAddress,
    customerEmail: v.optional(v.pipe(v.string(), v.email())),
  }),
};

// Runtime validation function
export function validateOrder(payload: unknown): v.InferInput<typeof ValidationRegistry.OrderPayload> {
  return v.parse(ValidationRegistry.OrderPayload, payload);
}

Quick Start Guide

  1. Install Valibot: Run npm install valibot and ensure your TypeScript config targets ES2020+ for optimal tree-shaking.
  2. Generate Schema: Pass a representative JSON payload to the emitter. The output will include namespace imports and children-first declarations.
  3. Refine Types: Replace bare primitives with v.pipe() refinements based on domain constraints (e.g., email format, numeric ranges).
  4. Integrate Validation: Import the generated schema registry and call v.parse() at API boundaries, message consumers, or configuration loaders.
  5. Verify Bundle: Run your build pipeline and inspect the output. Unused combinators should be eliminated, confirming tree-shaking efficiency.