Skip to main content

Schema Validation Pattern

This comprehensive guide covers message schema validation in NatsPubsub, ensuring data quality and type safety across your distributed system.

Table of Contents​


Overview​

Schema validation ensures messages conform to expected structures before processing, preventing errors and data corruption in distributed systems.

Benefits​

  • Type Safety: Catch type errors at runtime
  • Data Quality: Ensure valid, complete data
  • Documentation: Schemas serve as contracts
  • Error Prevention: Fail fast on invalid data
  • API Stability: Enforce message contracts between services

Why Validate Messages​

Problem: Invalid Messages​

Without validation, invalid messages can cause:

// ❌ No validation - runtime errors
async function processOrder(message: any) {
// Crashes if orderId is missing
const order = await db.orders.find(message.orderId);

// Crashes if total is not a number
const tax = message.total * 0.1;

// Crashes if items is not an array
message.items.forEach((item) => processItem(item));
}

Solution: Schema Validation​

// ✅ With validation - safe processing
const OrderSchema = z.object({
orderId: z.string().uuid(),
customerId: z.string().uuid(),
total: z.number().positive(),
items: z
.array(
z.object({
productId: z.string(),
quantity: z.number().int().positive(),
price: z.number().positive(),
}),
)
.min(1),
});

async function processOrder(message: unknown) {
// Validate first
const result = OrderSchema.safeParse(message);

if (!result.success) {
throw new ValidationError("Invalid order", result.error);
}

// Now safe to process
const order = result.data;
// TypeScript knows the exact shape!
}

When to Validate​

LocationWhenWhy
PublisherBefore publishingPrevent invalid data from entering system
SubscriberAfter receivingDefensive programming, trust no one
API GatewayAt entry pointValidate external inputs
SerializationBefore storingEnsure data integrity

Validation Approaches​

Validate at runtime using libraries like Zod or JSON Schema:

// Zod (JavaScript/TypeScript)
const schema = z.object({ name: z.string() });
const result = schema.safeParse(data);

// JSON Schema (Universal)
const validate = ajv.compile(schema);
const valid = validate(data);

2. Static Type Checking​

Use TypeScript for compile-time validation:

interface Order {
orderId: string;
total: number;
}

// Type error caught at compile time
const order: Order = {
orderId: "123",
total: "99.99", // ❌ Type error: string not assignable to number
};

3. Hybrid Approach (Best)​

Combine static and runtime validation:

// Static types for development
interface Order {
orderId: string;
total: number;
}

// Runtime validation for production
const OrderSchema = z.object({
orderId: z.string().uuid(),
total: z.number().positive(),
});

type Order = z.infer<typeof OrderSchema>; // Types from schema

Zod Integration (JavaScript)​

Zod is a TypeScript-first schema validation library with excellent type inference.

Basic Validation​

import { z } from "zod";
import { SchemaValidator } from "nats-pubsub";

// Define schema
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1).max(100),
age: z.number().int().positive().optional(),
role: z.enum(["admin", "user", "guest"]),
createdAt: z.date(),
});

// Create validator
const validator = new SchemaValidator(UserSchema);

// Validate data
const result = validator.validate({
id: "550e8400-e29b-41d4-a716-446655440000",
email: "user@example.com",
name: "John Doe",
role: "user",
createdAt: new Date(),
});

if (result.success) {
console.log("Valid user:", result.data);
// result.data has full type inference!
} else {
console.error("Validation errors:", result.errors);
}

Advanced Schemas​

Nested Objects​

const AddressSchema = z.object({
street: z.string(),
city: z.string(),
state: z.string().length(2),
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/),
});

const CustomerSchema = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
address: AddressSchema,
billingAddress: AddressSchema.optional(),
});

Arrays and Collections​

const OrderItemSchema = z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
price: z.number().positive(),
});

const OrderSchema = z.object({
orderId: z.string().uuid(),
customerId: z.string().uuid(),
items: z
.array(OrderItemSchema)
.min(1, "Order must have at least one item")
.max(100, "Order cannot have more than 100 items"),
total: z.number().positive(),
status: z.enum(["pending", "processing", "completed", "cancelled"]),
});

Custom Validations​

const OrderSchema = z
.object({
orderId: z.string().uuid(),
items: z.array(OrderItemSchema),
total: z.number().positive(),
})
.refine(
(order) => {
// Custom validation: total must match sum of items
const itemsTotal = order.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
return Math.abs(order.total - itemsTotal) < 0.01; // Allow floating point error
},
{
message: "Order total must match sum of item prices",
path: ["total"],
},
);

Transformations​

const DateStringSchema = z
.string()
.datetime()
.transform((str) => new Date(str));

const OrderSchema = z.object({
orderId: z.string().uuid(),
createdAt: DateStringSchema, // Transforms string to Date
total: z.string().transform((val) => parseFloat(val)), // String to number
});

// Input: { orderId: '...', createdAt: '2025-01-17T12:00:00Z', total: '99.99' }
// Output: { orderId: '...', createdAt: Date object, total: 99.99 }

Schema Composition​

Extending Schemas​

const BaseEventSchema = z.object({
eventId: z.string().uuid(),
occurredAt: z.date(),
version: z.string(),
});

const OrderCreatedEventSchema = BaseEventSchema.extend({
eventType: z.literal("order.created"),
orderId: z.string().uuid(),
customerId: z.string().uuid(),
total: z.number().positive(),
});

const OrderCancelledEventSchema = BaseEventSchema.extend({
eventType: z.literal("order.cancelled"),
orderId: z.string().uuid(),
reason: z.string(),
});

Union Types​

const OrderEventSchema = z.discriminatedUnion("eventType", [
OrderCreatedEventSchema,
OrderUpdatedEventSchema,
OrderCancelledEventSchema,
]);

// Validates any order event type
const result = OrderEventSchema.safeParse(data);

if (result.success) {
// TypeScript knows which event type based on eventType field
switch (result.data.eventType) {
case "order.created":
// result.data.orderId available
break;
case "order.cancelled":
// result.data.reason available
break;
}
}

Partial Schemas​

const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string(),
});

// For updates - all fields optional
const UserUpdateSchema = UserSchema.partial();

// For specific fields - only some required
const UserProfileSchema = UserSchema.pick({
name: true,
email: true,
});

Integration with Subscribers​

import { Subscriber, MessageContext } from "nats-pubsub";
import { SchemaValidator } from "nats-pubsub";
import { z } from "zod";

const OrderSchema = z.object({
orderId: z.string().uuid(),
customerId: z.string().uuid(),
total: z.number().positive(),
items: z.array(
z.object({
productId: z.string(),
quantity: z.number().int().positive(),
price: z.number().positive(),
}),
),
});

class OrderSubscriber extends Subscriber {
private validator = new SchemaValidator(OrderSchema);

constructor() {
super("production.*.order.created");
}

async handle(
message: Record<string, unknown>,
metadata: MessageContext,
): Promise<void> {
// Validate message
const result = this.validator.validate(message);

if (!result.success) {
throw new Error(
`Invalid order message: ${result.errors.map((e) => `${e.path}: ${e.message}`).join(", ")}`,
);
}

// Process validated message
const order = result.data;
await this.processOrder(order);
}

private async processOrder(
order: z.infer<typeof OrderSchema>,
): Promise<void> {
// TypeScript knows exact shape of order
console.log("Processing order:", order.orderId);
}
}

Common Schemas​

import { CommonSchemas, createMessageSchema } from "nats-pubsub";

// Use built-in common schemas
const UserSchema = z.object({
id: CommonSchemas.uuid,
email: CommonSchemas.email,
website: CommonSchemas.url,
createdAt: CommonSchemas.isoDate,
});

// Create message schema with metadata
const UserCreatedMessageSchema = createMessageSchema(
z.object({
userId: CommonSchemas.uuid,
email: CommonSchemas.email,
name: CommonSchemas.nonEmptyString,
}),
);

// Result includes payload and metadata fields
type UserCreatedMessage = z.infer<typeof UserCreatedMessageSchema>;
// {
// payload: { userId: string, email: string, name: string },
// metadata?: { eventId?: string, traceId?: string, ... }
// }

JSON Schema Validation​

JSON Schema is a language-agnostic schema format supported across all platforms.

JavaScript Implementation​

import Ajv from "ajv";
import addFormats from "ajv-formats";

// Create validator
const ajv = new Ajv({ allErrors: true });
addFormats(ajv); // Add format validation (email, uri, etc.)

// Define schema
const orderSchema = {
type: "object",
required: ["orderId", "customerId", "total", "items"],
properties: {
orderId: {
type: "string",
format: "uuid",
},
customerId: {
type: "string",
format: "uuid",
},
total: {
type: "number",
minimum: 0,
},
items: {
type: "array",
minItems: 1,
items: {
type: "object",
required: ["productId", "quantity", "price"],
properties: {
productId: { type: "string" },
quantity: { type: "integer", minimum: 1 },
price: { type: "number", minimum: 0 },
},
},
},
status: {
type: "string",
enum: ["pending", "processing", "completed", "cancelled"],
},
},
};

// Compile schema
const validate = ajv.compile(orderSchema);

// Validate data
const valid = validate(orderData);

if (!valid) {
console.error("Validation errors:", validate.errors);
}

Ruby Implementation​

require 'json-schema'

# Define schema
order_schema = {
type: 'object',
required: ['order_id', 'customer_id', 'total', 'items'],
properties: {
order_id: {
type: 'string',
pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
},
customer_id: {
type: 'string',
pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
},
total: {
type: 'number',
minimum: 0
},
items: {
type: 'array',
minItems: 1,
items: {
type: 'object',
required: ['product_id', 'quantity', 'price'],
properties: {
product_id: { type: 'string' },
quantity: { type: 'integer', minimum: 1 },
price: { type: 'number', minimum: 0 }
}
}
},
status: {
type: 'string',
enum: ['pending', 'processing', 'completed', 'cancelled']
}
}
}

# Validate
begin
JSON::Validator.validate!(order_schema, order_data)
puts 'Valid order'
rescue JSON::Schema::ValidationError => e
puts "Invalid order: #{e.message}"
end

Subscriber with JSON Schema​

# app/subscribers/order_subscriber.rb
class OrderSubscriber < NatsPubsub::Subscriber
subscribe_to "order.*"

ORDER_SCHEMA = {
type: 'object',
required: ['order_id', 'customer_id', 'total'],
properties: {
order_id: { type: 'string' },
customer_id: { type: 'string' },
total: { type: 'number', minimum: 0 }
}
}

def handle(message, context)
# Validate message
begin
JSON::Validator.validate!(ORDER_SCHEMA, message)
rescue JSON::Schema::ValidationError => e
Rails.logger.error "Invalid order message: #{e.message}"
raise NatsPubsub::ValidationError, e.message
end

# Process validated message
process_order(message)
end

private

def process_order(order)
Order.create!(
id: order['order_id'],
customer_id: order['customer_id'],
total: order['total']
)
end
end

Custom Validators​

JavaScript Validators​

Custom Validation Class​

import { ValidationError } from "nats-pubsub";

class OrderValidator {
validate(order: any): void {
const errors: string[] = [];

// Validate order ID
if (!order.orderId || typeof order.orderId !== "string") {
errors.push("orderId is required and must be a string");
}

// Validate total
if (typeof order.total !== "number" || order.total <= 0) {
errors.push("total must be a positive number");
}

// Validate items
if (!Array.isArray(order.items) || order.items.length === 0) {
errors.push("items must be a non-empty array");
} else {
order.items.forEach((item: any, index: number) => {
if (!item.productId) {
errors.push(`items[${index}].productId is required`);
}
if (typeof item.quantity !== "number" || item.quantity < 1) {
errors.push(`items[${index}].quantity must be a positive number`);
}
});
}

// Validate total matches items
if (Array.isArray(order.items)) {
const calculatedTotal = order.items.reduce(
(sum: number, item: any) => sum + item.price * item.quantity,
0,
);

if (Math.abs(order.total - calculatedTotal) > 0.01) {
errors.push("total does not match sum of item prices");
}
}

if (errors.length > 0) {
throw new ValidationError("Order validation failed", errors);
}
}
}

// Usage
const validator = new OrderValidator();
try {
validator.validate(orderData);
} catch (error) {
if (error instanceof ValidationError) {
console.error("Validation errors:", error.errors);
}
}

Decorator-Based Validation​

import { Subscriber, MessageContext } from "nats-pubsub";

function ValidateSchema(schema: any) {
return function (
target: any,
propertyName: string,
descriptor: PropertyDescriptor,
) {
const method = descriptor.value;

descriptor.value = async function (message: any, metadata: MessageContext) {
// Validate before calling method
const result = schema.safeParse(message);

if (!result.success) {
throw new Error(`Validation failed: ${JSON.stringify(result.error)}`);
}

// Call original method with validated data
return method.call(this, result.data, metadata);
};

return descriptor;
};
}

// Usage
class OrderSubscriber extends Subscriber {
@ValidateSchema(OrderSchema)
async handle(message: Order, metadata: MessageContext): Promise<void> {
// message is guaranteed to be valid Order type
await this.processOrder(message);
}

private async processOrder(order: Order): Promise<void> {
console.log("Processing order:", order.orderId);
}
}

Ruby Validators​

Custom Validator Class​

# app/validators/order_validator.rb
class OrderValidator
def self.validate!(order)
errors = []

# Validate order_id
errors << 'order_id is required' unless order['order_id'].present?

# Validate total
unless order['total'].is_a?(Numeric) && order['total'] > 0
errors << 'total must be a positive number'
end

# Validate items
if order['items'].blank? || !order['items'].is_a?(Array)
errors << 'items must be a non-empty array'
else
order['items'].each_with_index do |item, index|
errors << "items[#{index}].product_id is required" unless item['product_id'].present?
unless item['quantity'].is_a?(Integer) && item['quantity'] > 0
errors << "items[#{index}].quantity must be a positive integer"
end
end
end

# Validate total matches items
if order['items'].is_a?(Array)
calculated_total = order['items'].sum { |item| item['price'] * item['quantity'] }
if (order['total'] - calculated_total).abs > 0.01
errors << 'total does not match sum of item prices'
end
end

raise ValidationError, errors.join(', ') if errors.any?
end
end

# Usage
begin
OrderValidator.validate!(order_data)
puts 'Valid order'
rescue ValidationError => e
puts "Invalid order: #{e.message}"
end

ActiveModel Validations​

# app/models/order_message.rb
class OrderMessage
include ActiveModel::Model
include ActiveModel::Validations

attr_accessor :order_id, :customer_id, :total, :items, :status

validates :order_id, presence: true, format: { with: /\A[0-9a-f-]{36}\z/ }
validates :customer_id, presence: true, format: { with: /\A[0-9a-f-]{36}\z/ }
validates :total, presence: true, numericality: { greater_than: 0 }
validates :items, presence: true
validates :status, inclusion: { in: %w[pending processing completed cancelled] }

validate :items_must_be_valid

private

def items_must_be_valid
return if items.blank?

unless items.is_a?(Array)
errors.add(:items, 'must be an array')
return
end

items.each_with_index do |item, index|
errors.add(:items, "[#{index}] must have product_id") unless item['product_id'].present?
unless item['quantity'].is_a?(Integer) && item['quantity'] > 0
errors.add(:items, "[#{index}] quantity must be positive integer")
end
end
end
end

# Usage in subscriber
class OrderSubscriber < NatsPubsub::Subscriber
subscribe_to "order.*"

def handle(message, context)
order = OrderMessage.new(message)

unless order.valid?
raise ValidationError, order.errors.full_messages.join(', ')
end

process_order(order)
end

private

def process_order(order)
Order.create!(
id: order.order_id,
customer_id: order.customer_id,
total: order.total
)
end
end

Versioned Schemas​

Schema Evolution​

// Version 1
const OrderSchemaV1 = z.object({
orderId: z.string(),
total: z.number(),
});

// Version 2 - Added customerId
const OrderSchemaV2 = z.object({
orderId: z.string(),
customerId: z.string(), // New required field
total: z.number(),
});

// Version 3 - Added items array
const OrderSchemaV3 = z.object({
orderId: z.string(),
customerId: z.string(),
total: z.number(),
items: z.array(
z.object({
productId: z.string(),
quantity: z.number(),
}),
),
});

Backward Compatibility​

// Make new fields optional for backward compatibility
const OrderSchemaV2Compatible = z.object({
orderId: z.string(),
customerId: z.string().optional(), // Optional for v1 messages
total: z.number(),
});

// Transform old format to new format
const OrderSchema = z
.object({
orderId: z.string(),
customerId: z.string().optional(),
total: z.number(),
})
.transform((data) => ({
...data,
customerId: data.customerId || "unknown", // Default for v1 messages
}));

Version Management​

interface VersionedMessage<T> {
version: string;
data: T;
}

const OrderSchemaV1 = z.object({
orderId: z.string(),
total: z.number(),
});

const OrderSchemaV2 = z.object({
orderId: z.string(),
customerId: z.string(),
total: z.number(),
});

const schemas = {
"1.0": OrderSchemaV1,
"2.0": OrderSchemaV2,
};

function validateVersionedMessage(message: VersionedMessage<unknown>) {
const schema = schemas[message.version];

if (!schema) {
throw new Error(`Unsupported schema version: ${message.version}`);
}

return schema.parse(message.data);
}

// Usage
const message = {
version: "2.0",
data: {
orderId: "123",
customerId: "456",
total: 99.99,
},
};

const validatedData = validateVersionedMessage(message);

Ruby Version Management​

# app/services/schema_validator.rb
class SchemaValidator
SCHEMAS = {
'1.0' => {
type: 'object',
required: ['order_id', 'total'],
properties: {
order_id: { type: 'string' },
total: { type: 'number' }
}
},
'2.0' => {
type: 'object',
required: ['order_id', 'customer_id', 'total'],
properties: {
order_id: { type: 'string' },
customer_id: { type: 'string' },
total: { type: 'number' }
}
}
}

def self.validate!(message, version)
schema = SCHEMAS[version]

raise "Unsupported schema version: #{version}" unless schema

JSON::Validator.validate!(schema, message)
end
end

# Usage in subscriber
class OrderSubscriber < NatsPubsub::Subscriber
subscribe_to "order.*"

def handle(message, context)
version = context.headers['schema-version'] || '1.0'

begin
SchemaValidator.validate!(message, version)
rescue => e
Rails.logger.error "Schema validation failed: #{e.message}"
raise
end

process_order(message, version)
end

private

def process_order(message, version)
case version
when '1.0'
# Handle v1 format
process_order_v1(message)
when '2.0'
# Handle v2 format
process_order_v2(message)
end
end
end

Validation Error Handling​

Error Messages​

import { ValidationError } from "nats-pubsub";

class ValidationMiddleware {
async call(
event: Record<string, unknown>,
metadata: any,
next: () => Promise<void>,
): Promise<void> {
try {
await next();
} catch (error) {
if (error instanceof ValidationError) {
// Format validation errors
const formattedErrors = error.errors.map((e) => ({
field: e.path,
message: e.message,
code: e.code,
}));

console.error("Validation failed:", {
eventId: metadata.eventId,
subject: metadata.subject,
errors: formattedErrors,
});

// Send to DLQ with error details
throw new Error(
JSON.stringify({
type: "validation_error",
errors: formattedErrors,
original: event,
}),
);
}

throw error;
}
}
}

Error Recovery​

class ValidationErrorRecovery {
async handleValidationError(
message: any,
error: ValidationError,
metadata: MessageContext,
): Promise<void> {
// Log detailed error
console.error("Validation error:", {
eventId: metadata.eventId,
subject: metadata.subject,
errors: error.errors,
message,
});

// Try to fix common issues
const fixed = this.attemptAutoFix(message, error);

if (fixed) {
console.log("Auto-fixed validation errors, retrying");
return this.retryWithFixedMessage(fixed, metadata);
}

// Cannot auto-fix, send to DLQ
await this.sendToDLQ(message, error, metadata);
}

private attemptAutoFix(message: any, error: ValidationError): any | null {
// Try to fix common issues
const fixed = { ...message };
let canFix = true;

for (const err of error.errors) {
if (err.path === "customerId" && !message.customerId) {
// Add default customerId
fixed.customerId = "unknown";
} else if (err.path === "items" && !Array.isArray(message.items)) {
// Convert single item to array
fixed.items = [message.items];
} else {
canFix = false;
break;
}
}

return canFix ? fixed : null;
}
}

Validation Middleware​

JavaScript Validation Middleware​

import { Middleware, EventMetadata } from "nats-pubsub";
import { z } from "zod";

class SchemaValidationMiddleware implements Middleware {
constructor(private schemas: Map<string, z.ZodType>) {}

async call(
event: Record<string, unknown>,
metadata: EventMetadata,
next: () => Promise<void>,
): Promise<void> {
// Find schema for this subject
const schema = this.findSchema(metadata.subject);

if (schema) {
// Validate message
const result = schema.safeParse(event);

if (!result.success) {
throw new Error(
`Schema validation failed for ${metadata.subject}: ${JSON.stringify(result.error.errors)}`,
);
}

// Replace event with validated data
Object.assign(event, result.data);
}

await next();
}

private findSchema(subject: string): z.ZodType | undefined {
// Exact match
if (this.schemas.has(subject)) {
return this.schemas.get(subject);
}

// Pattern match (e.g., "order.*" matches "order.created")
for (const [pattern, schema] of this.schemas) {
if (this.matchesPattern(subject, pattern)) {
return schema;
}
}

return undefined;
}

private matchesPattern(subject: string, pattern: string): boolean {
const regex = new RegExp(
"^" + pattern.replace(/\*/g, "[^.]+").replace(/>/g, ".+") + "$",
);
return regex.test(subject);
}
}

// Register globally
const schemas = new Map([
["production.*.order.created", OrderCreatedSchema],
["production.*.order.updated", OrderUpdatedSchema],
["production.*.payment.*", PaymentSchema],
]);

NatsPubsub.use(new SchemaValidationMiddleware(schemas));

Ruby Validation Middleware​

# app/middleware/schema_validation_middleware.rb
class SchemaValidationMiddleware
def initialize(schemas = {})
@schemas = schemas
end

def call(message, context)
schema = find_schema(context.subject)

if schema
begin
JSON::Validator.validate!(schema, message)
rescue JSON::Schema::ValidationError => e
Rails.logger.error "Schema validation failed for #{context.subject}: #{e.message}"
raise NatsPubsub::ValidationError, e.message
end
end

yield
end

private

def find_schema(subject)
# Exact match
return @schemas[subject] if @schemas.key?(subject)

# Pattern match
@schemas.each do |pattern, schema|
return schema if matches_pattern?(subject, pattern)
end

nil
end

def matches_pattern?(subject, pattern)
regex_pattern = "^#{pattern.gsub('*', '[^.]+').gsub('>', '.+')}$"
Regexp.new(regex_pattern).match?(subject)
end
end

# Register globally
NatsPubsub.configure do |config|
schemas = {
'production.*.order.created' => ORDER_CREATED_SCHEMA,
'production.*.order.updated' => ORDER_UPDATED_SCHEMA,
'production.*.payment.*' => PAYMENT_SCHEMA
}

config.middleware.use SchemaValidationMiddleware.new(schemas)
end

Performance Optimization​

Schema Caching​

class CachedSchemaValidator {
private validators = new Map<string, z.ZodType>();

getValidator(schemaKey: string, schemaFn: () => z.ZodType): z.ZodType {
if (!this.validators.has(schemaKey)) {
this.validators.set(schemaKey, schemaFn());
}
return this.validators.get(schemaKey)!;
}
}

const cache = new CachedSchemaValidator();

// Use cached validator
const validator = cache.getValidator("order", () => OrderSchema);

Lazy Validation​

// Only validate if needed
class LazyValidator {
private validated = false;
private validationResult: any = null;

constructor(
private data: any,
private schema: z.ZodType,
) {}

get isValid(): boolean {
if (!this.validated) {
this.validate();
}
return this.validationResult.success;
}

get data(): any {
if (!this.validated) {
this.validate();
}
if (!this.validationResult.success) {
throw new Error("Data is invalid");
}
return this.validationResult.data;
}

private validate(): void {
this.validationResult = this.schema.safeParse(this.data);
this.validated = true;
}
}

Partial Validation​

// Only validate changed fields
function validatePartial<T>(schema: z.ZodType<T>, data: Partial<T>): boolean {
const partialSchema = schema.partial();
const result = partialSchema.safeParse(data);
return result.success;
}

Testing Validation​

JavaScript Tests​

import { describe, it, expect } from "@jest/globals";
import { OrderSchema } from "./schemas";

describe("OrderSchema", () => {
it("validates valid order", () => {
const validOrder = {
orderId: "550e8400-e29b-41d4-a716-446655440000",
customerId: "550e8400-e29b-41d4-a716-446655440001",
total: 99.99,
items: [{ productId: "prod-1", quantity: 2, price: 49.99 }],
};

const result = OrderSchema.safeParse(validOrder);
expect(result.success).toBe(true);
});

it("rejects order with negative total", () => {
const invalidOrder = {
orderId: "550e8400-e29b-41d4-a716-446655440000",
customerId: "550e8400-e29b-41d4-a716-446655440001",
total: -10,
items: [],
};

const result = OrderSchema.safeParse(invalidOrder);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.errors[0].path).toContain("total");
}
});

it("rejects order with empty items", () => {
const invalidOrder = {
orderId: "550e8400-e29b-41d4-a716-446655440000",
customerId: "550e8400-e29b-41d4-a716-446655440001",
total: 99.99,
items: [],
};

const result = OrderSchema.safeParse(invalidOrder);
expect(result.success).toBe(false);
});
});

Ruby Tests​

# spec/validators/order_validator_spec.rb
RSpec.describe OrderValidator do
describe '.validate!' do
it 'validates valid order' do
valid_order = {
'order_id' => SecureRandom.uuid,
'customer_id' => SecureRandom.uuid,
'total' => 99.99,
'items' => [
{ 'product_id' => 'prod-1', 'quantity' => 2, 'price' => 49.99 }
]
}

expect { OrderValidator.validate!(valid_order) }.not_to raise_error
end

it 'rejects order with negative total' do
invalid_order = {
'order_id' => SecureRandom.uuid,
'customer_id' => SecureRandom.uuid,
'total' => -10,
'items' => []
}

expect { OrderValidator.validate!(invalid_order) }.to raise_error(ValidationError)
end

it 'rejects order with empty items' do
invalid_order = {
'order_id' => SecureRandom.uuid,
'customer_id' => SecureRandom.uuid,
'total' => 99.99,
'items' => []
}

expect { OrderValidator.validate!(invalid_order) }.to raise_error(ValidationError, /items/)
end
end
end

Best Practices​

1. Validate at Boundaries​

// ✅ GOOD: Validate at system boundaries
// Publisher validates before sending
await publisher.publish(OrderSchema.parse(order));

// Subscriber validates after receiving
const validated = OrderSchema.parse(message);

// ❌ BAD: No validation
await publisher.publish(order); // Hope it's valid!

2. Use Strict Schemas​

// ✅ GOOD: Strict schema, no extra fields
const OrderSchema = z
.object({
orderId: z.string(),
total: z.number(),
})
.strict();

// ❌ BAD: Allows any extra fields
const OrderSchema = z
.object({
orderId: z.string(),
total: z.number(),
})
.passthrough();

3. Version Your Schemas​

// ✅ GOOD: Include version in message
const message = {
version: "2.0",
data: order,
};

// Validate based on version
const schema = schemas[message.version];

4. Document Schemas​

// ✅ GOOD: Well-documented schema
const OrderSchema = z.object({
/** Unique order identifier (UUID v4) */
orderId: z.string().uuid(),

/** Customer identifier */
customerId: z.string().uuid(),

/** Total order amount in USD, must be positive */
total: z.number().positive(),

/** Order line items, at least one required */
items: z.array(OrderItemSchema).min(1),
});

5. Handle Errors Gracefully​

// ✅ GOOD: Detailed error handling
const result = OrderSchema.safeParse(data);
if (!result.success) {
console.error("Validation failed:", {
errors: result.error.errors,
data,
});
throw new ValidationError("Invalid order", result.error);
}

// ❌ BAD: Silent failure
try {
OrderSchema.parse(data);
} catch (e) {
// Ignore
}

Common Patterns​

Pattern 1: Envelope Validation​

const EnvelopeSchema = z.object({
metadata: z.object({
eventId: z.string().uuid(),
occurredAt: z.date(),
version: z.string(),
}),
payload: z.unknown(), // Validated separately based on type
});

// Validate envelope, then payload
const envelope = EnvelopeSchema.parse(message);
const payload = getSchemaForType(envelope.metadata.type).parse(
envelope.payload,
);

Pattern 2: Discriminated Unions​

const EventSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("order.created"),
orderId: z.string(),
total: z.number(),
}),
z.object({
type: z.literal("order.cancelled"),
orderId: z.string(),
reason: z.string(),
}),
]);

Pattern 3: Transform and Validate​

const OrderSchema = z.object({
orderId: z.string(),
createdAt: z.string().transform((str) => new Date(str)),
total: z.string().transform((str) => parseFloat(str)),
});

// Input: { orderId: '123', createdAt: '2025-01-17', total: '99.99' }
// Output: { orderId: '123', createdAt: Date object, total: 99.99 }

Troubleshooting​

Issue: Schema Validation Failures​

Solution:

// Add detailed logging
const result = schema.safeParse(data);
if (!result.success) {
console.error("Schema:", schema);
console.error("Data:", JSON.stringify(data, null, 2));
console.error("Errors:", result.error.errors);
}

Issue: Performance Degradation​

Solution:

// Cache compiled schemas
const schemaCache = new Map();

function getSchema(key: string) {
if (!schemaCache.has(key)) {
schemaCache.set(key, compileSchema(key));
}
return schemaCache.get(key);
}

Documentation​

External Resources​


Navigation: