← All articles
LANGUAGES Schema Validation Tools: Zod, JSON Schema, and Proto... 2026-02-09 · 8 min read · typescript · zod · json-schema

Schema Validation Tools: Zod, JSON Schema, and Protobuf Compared

Languages 2026-02-09 · 8 min read typescript zod json-schema protobuf validation types

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 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

Use JSON Schema When

Use Protobuf When

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.