How to convert a JSON sample to a Valibot schema (and the 3 ways the algorithm diverges from Zod)
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:
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; }Namespace Imports: The output header must use
import * as v from "valibot"to guarantee tree-shaking. Destructured imports prevent bundlers from dropping unused combinators.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
constdeclarations do not hoist. Referencing a schema before its declaration throws aReferenceError. 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 viaimport * 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 withv.nullable()orv.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"andsideEffects: false. - Replace destructured imports: Migrate all
import { ... } from "valibot"toimport * 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()withv.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-visualizerorsource-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
- Install Valibot: Run
npm install valibotand ensure your TypeScript config targets ES2020+ for optimal tree-shaking. - Generate Schema: Pass a representative JSON payload to the emitter. The output will include namespace imports and children-first declarations.
- Refine Types: Replace bare primitives with
v.pipe()refinements based on domain constraints (e.g., email format, numeric ranges). - Integrate Validation: Import the generated schema registry and call
v.parse()at API boundaries, message consumers, or configuration loaders. - Verify Bundle: Run your build pipeline and inspect the output. Unused combinators should be eliminated, confirming tree-shaking efficiency.
