Schema Validation Tools: Zod, JSON Schema, and Protobuf Compared
Schema Validation Tools: Zod, JSON Schema, and Protobuf Compared
TypeScript gives you compile-time types, but types disappear at runtime. Data from APIs, form inputs, environment variables, and external services arrives as unknown at best and any at worst. Schema validation bridges the gap between "the type says this is a User" and "this is actually a User."
The three major approaches -- Zod, JSON Schema, and Protocol Buffers -- solve this problem differently. Each has a sweet spot, and picking the right one depends on where your boundaries are and what languages you work in.
The Problem: Runtime vs Compile-Time
Consider a TypeScript API endpoint:
interface CreateUserRequest {
name: string;
email: string;
age: number;
}
app.post("/users", (req, res) => {
const body: CreateUserRequest = req.body; // This is a lie
// body.age could be "twenty-five" or undefined or anything
});
TypeScript's type assertion does nothing at runtime. req.body is whatever the client sent. Without validation, you're trusting external input -- which is how injection attacks, crashes from undefined property access, and corrupt database records happen.
Schema validation tools solve this by defining schemas that are checked at runtime and (in the best case) also produce TypeScript types.
Zod: TypeScript-First Validation
Zod is a TypeScript-first schema declaration and validation library. You define a schema, and Zod gives you both runtime validation and a TypeScript type inferred from the schema.
Basic Usage
import { z } from "zod";
const UserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(13).max(150),
role: z.enum(["admin", "user", "moderator"]).default("user"),
bio: z.string().optional(),
});
// Infer the TypeScript type from the schema
type User = z.infer<typeof UserSchema>;
// { name: string; email: string; age: number; role: "admin" | "user" | "moderator"; bio?: string }
// Validate data
const result = UserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
const user: User = result.data; // Fully typed and validated
The key insight: you write the schema once and get both the runtime validator and the TypeScript type. No duplication, no drift between your type definition and your validation logic.
Transformations and Coercion
Zod handles the common case where incoming data has the right value but wrong type (e.g., query parameters are always strings):
const QuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
search: z.string().optional(),
active: z.coerce.boolean().default(true),
});
// "?page=2&limit=50&active=true" parses correctly
const query = QuerySchema.parse(req.query);
// { page: 2, limit: 50, search: undefined, active: true }
Transformations let you reshape data during validation:
const DateRangeSchema = z.object({
start: z.string().transform((s) => new Date(s)),
end: z.string().transform((s) => new Date(s)),
}).refine(
(data) => data.start < data.end,
{ message: "Start date must be before end date" }
);
Composability
Zod schemas compose naturally:
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
state: z.string().length(2),
zip: z.string().regex(/^\d{5}(-\d{4})?$/),
});
const PersonSchema = z.object({
name: z.string(),
address: AddressSchema,
});
// Extend schemas
const EmployeeSchema = PersonSchema.extend({
employeeId: z.string().uuid(),
department: z.string(),
});
// Pick/omit fields
const CreatePersonInput = PersonSchema.omit({ address: true });
const PersonNameOnly = PersonSchema.pick({ name: true });
// Merge schemas
const FullProfile = PersonSchema.merge(z.object({
avatar: z.string().url(),
joinedAt: z.date(),
}));
Zod Ecosystem
Zod's popularity has spawned an ecosystem of integrations:
- tRPC uses Zod schemas for input/output validation on API routes
- React Hook Form integrates via
@hookform/resolvers/zodfor form validation - Hono and Express middleware can validate request bodies with Zod
- zod-to-json-schema converts Zod schemas to JSON Schema for OpenAPI docs
- drizzle-zod generates Zod schemas from Drizzle ORM table definitions
// tRPC example: schema validates both sides of the API
const appRouter = router({
createUser: publicProcedure
.input(UserSchema)
.mutation(async ({ input }) => {
// input is fully typed and validated
return db.users.create(input);
}),
});
Zod's strengths: Single source of truth for types and validation, excellent TypeScript inference, great composability, large ecosystem.
Zod's weaknesses: TypeScript/JavaScript only, schemas are code (not portable to other languages), runtime overhead for high-throughput paths, bundle size (~13 KB minified+gzipped).
JSON Schema: The Language-Agnostic Standard
JSON Schema is a specification for describing JSON data structures. It's language-agnostic -- validators exist for every major language. If you need to validate data across language boundaries or generate documentation from schemas, JSON Schema is the standard.
Basic Usage
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 100
},
"email": {
"type": "string",
"format": "email"
},
"age": {
"type": "integer",
"minimum": 13,
"maximum": 150
},
"role": {
"type": "string",
"enum": ["admin", "user", "moderator"],
"default": "user"
},
"bio": {
"type": "string"
}
},
"required": ["name", "email", "age"],
"additionalProperties": false
}
Validation in Different Languages
// TypeScript (ajv)
import Ajv from "ajv";
import addFormats from "ajv-formats";
const ajv = new Ajv({ allErrors: true });
addFormats(ajv);
const validate = ajv.compile(userSchema);
const valid = validate(data);
if (!valid) {
console.log(validate.errors);
}
# Python (jsonschema)
from jsonschema import validate, ValidationError
try:
validate(instance=data, schema=user_schema)
except ValidationError as e:
print(e.message)
// Go (gojsonschema)
loader := gojsonschema.NewGoLoader(userSchema)
documentLoader := gojsonschema.NewGoLoader(data)
result, _ := gojsonschema.Validate(loader, documentLoader)
if !result.Valid() {
for _, err := range result.Errors() {
fmt.Println(err)
}
}
OpenAPI Integration
JSON Schema is the foundation of OpenAPI (Swagger). Your API documentation and validation can share the same schema:
# openapi.yaml
paths:
/users:
post:
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/CreateUser"
components:
schemas:
CreateUser:
type: object
required: [name, email, age]
properties:
name:
type: string
minLength: 1
email:
type: string
format: email
age:
type: integer
minimum: 13
Tools like openapi-generator can produce client SDKs in dozens of languages from this schema. Your TypeScript frontend, Go backend, and Python data pipeline all validate against the same contract.
Ajv: The Fastest JSON Schema Validator
Ajv (Another JSON Schema Validator) compiles schemas into optimized JavaScript functions. It is significantly faster than interpret-based validators:
import Ajv from "ajv";
import addFormats from "ajv-formats";
const ajv = new Ajv({
allErrors: true, // Report all errors, not just the first
removeAdditional: true, // Strip unknown properties
useDefaults: true, // Apply default values
coerceTypes: true, // Coerce strings to numbers etc.
});
addFormats(ajv);
// Compile once, validate many times
const validate = ajv.compile(schema);
// Type-safe usage with TypeScript
import { JTDDataType } from "ajv/dist/types/jtd-schema";
type User = JTDDataType<typeof schema>;
JSON Schema's strengths: Language-agnostic, industry standard, OpenAPI integration, tooling for code generation, well-specified.
JSON Schema's weaknesses: Verbose (JSON is not a great authoring format), TypeScript type inference requires extra tooling, no built-in transformations, the spec has multiple drafts and not all validators support the latest.
Protocol Buffers: Service Boundaries
Protocol Buffers (Protobuf) are Google's language-neutral, platform-neutral mechanism for serializing structured data. Where Zod validates JSON and JSON Schema describes JSON, Protobuf defines its own binary serialization format and generates code in multiple languages.
Defining Messages
// user.proto
syntax = "proto3";
package myapp;
message User {
string name = 1;
string email = 2;
int32 age = 3;
Role role = 4;
optional string bio = 5;
enum Role {
ROLE_UNSPECIFIED = 0;
ROLE_USER = 1;
ROLE_ADMIN = 2;
ROLE_MODERATOR = 3;
}
}
message CreateUserRequest {
string name = 1;
string email = 2;
int32 age = 3;
}
message CreateUserResponse {
User user = 1;
}
service UserService {
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
rpc GetUser(GetUserRequest) returns (User);
}
Code Generation
# Install protoc compiler
brew install protobuf # macOS
# Generate TypeScript (using ts-proto)
protoc --plugin=./node_modules/.bin/protoc-gen-ts_proto \
--ts_proto_out=./src/generated \
--ts_proto_opt=outputServices=nice-grpc \
./proto/user.proto
# Generate Go
protoc --go_out=./pkg/generated --go-grpc_out=./pkg/generated \
./proto/user.proto
# Generate Python
protoc --python_out=./generated --grpc_python_out=./generated \
./proto/user.proto
The generated code includes typed classes, serialization/deserialization, and (with gRPC plugins) client and server stubs.
Buf: Modern Protobuf Tooling
The buf CLI modernizes the Protobuf workflow -- linting, breaking change detection, and a schema registry:
# buf.yaml
version: v2
modules:
- path: proto
lint:
use:
- DEFAULT
breaking:
use:
- FILE
# Lint proto files
buf lint
# Check for breaking changes against main branch
buf breaking --against ".git#branch=main"
# Generate code
buf generate
Breaking change detection is Protobuf's killer feature for service evolution. Before merging a PR that modifies a .proto file, buf breaking tells you if you've made a backwards-incompatible change that would break existing clients.
Protobuf's strengths: Binary format (10x smaller and faster than JSON), multi-language code generation, gRPC integration, breaking change detection, excellent for service-to-service communication.
Protobuf's weaknesses: Not human-readable (binary format), overkill for browser-facing APIs, requires a code generation step, steeper learning curve, poor fit for dynamic or loosely-typed data.
When to Use Each
Use Zod When
- Your stack is TypeScript end-to-end (or mostly TypeScript)
- You need form validation in a React/Vue/Svelte app
- You want types and validation in one place
- You're building a tRPC or similar TypeScript-centric API
- You need transformations as part of validation (parsing, coercion)
Use JSON Schema When
- Your API serves clients in multiple languages
- You need OpenAPI documentation
- You want to validate configuration files
- You need a language-agnostic contract between teams
- You're building a public API that needs machine-readable documentation
Use Protobuf When
- You have service-to-service communication (microservices)
- Performance matters (high-throughput, low-latency)
- You need strict contracts across Go, Java, Python, TypeScript services
- You want gRPC for streaming or bidirectional communication
- You need breaking change detection in your CI pipeline
Practical Combinations
In practice, most projects use more than one approach:
TypeScript API with external clients:
Zod for internal validation and type inference, plus zod-to-json-schema to generate OpenAPI documentation for external consumers.
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
const UserSchema = z.object({
name: z.string(),
email: z.string().email(),
});
// Use for validation
const validated = UserSchema.parse(input);
// Generate JSON Schema for OpenAPI docs
const jsonSchema = zodToJsonSchema(UserSchema, "User");
Microservices with a TypeScript frontend: Protobuf for service-to-service communication, with a JSON API gateway that validates requests using Zod before forwarding to gRPC services.
Multi-language monorepo: JSON Schema as the canonical contract, with per-language validators (Ajv for TypeScript, jsonschema for Python, gojsonschema for Go).
Migration Paths
From No Validation to Zod
Start at your API boundaries. Validate incoming requests first, then expand to database query results and external API responses:
// Step 1: Validate API inputs
app.post("/users", (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) return res.status(400).json(result.error);
// ...
});
// Step 2: Validate database results
const users = UserSchema.array().parse(await db.query("SELECT * FROM users"));
// Step 3: Validate external API responses
const weather = WeatherResponseSchema.parse(await fetch(weatherApiUrl).then(r => r.json()));
From Zod to JSON Schema
If you outgrow Zod's TypeScript-only scope, use zod-to-json-schema to generate JSON Schemas from your existing Zod schemas. This lets you keep authoring in Zod while providing language-agnostic schemas for other teams.
From JSON to Protobuf
If JSON serialization becomes a bottleneck, introduce Protobuf for internal service communication while keeping JSON for external APIs. gRPC-Web or Connect can bridge the gap for browser clients.
The Bottom Line
Zod is the right default for TypeScript projects. It eliminates the type/validation duplication problem and has the best developer experience for the TypeScript ecosystem. JSON Schema is the right choice when you need to cross language boundaries or generate API documentation. Protobuf is the right choice for high-performance service-to-service communication where you control both ends.
The mistake to avoid: picking one tool and forcing it into every use case. Zod is a poor choice for Go service validation. JSON Schema is verbose for TypeScript form validation. Protobuf is overkill for a browser form. Use each where it fits, and bridge between them where needed.