| name | zod-schema |
| description | Generate Zod 4 schemas from examples or requirements. Creates type-safe validation schemas that serve as single source of truth for types, validation, and API docs. |
| paths | ["src/**/*.ts"] |
| trigger_phrase | {"haiku":"zod 4 schema type validation","opus":"zod 4 schema generation type safe validation","sonnet":"zod 4 schema type safe validation"} |
Zod Schema Generator
Overview
Generate Zod 4 schemas that serve as the single source of truth for TypeScript types, runtime validation, and OpenAPI documentation.
Announce at start: "Generating Zod schema using zod-schema skill."
IMPORTANT: This project requires Zod 4.x (not Zod 3.x).
Zod 4 Breaking Changes
If migrating from Zod 3 or following older tutorials, these changes are critical:
Error Customization
z.string().min(1, { message: 'Required' });
z.string({ invalid_type_error: 'Must be string' });
z.string().min(1, { error: 'Required' });
z.string().min(5, {
error: (ctx) => `Minimum ${ctx.minimum} characters required`
});
Top-Level Validators (Preferred in Zod 4)
z.email()
z.uuid()
z.url()
z.iso.datetime()
z.base64()
z.nanoid()
z.cuid()
z.cuid2()
z.ulid()
z.uuid().describe('Device ID').brand<'DeviceId'>()
Object Methods
z.object({ name: z.string() }).strict()
z.object({ name: z.string() }).passthrough()
z.strictObject({ name: z.string() })
z.looseObject({ name: z.string() })
Error Formatting
const result = Schema.safeParse(input);
if (!result.success) {
z.flattenError(result.error)
z.treeifyError(result.error)
z.prettifyError(result.error)
}
Function Schemas
z.function().args(z.string(), z.number()).returns(z.boolean())
z.function({
input: [z.string(), z.number()],
output: z.boolean()
})
Records
z.record(z.number())
z.record(z.string(), z.number())
Primitives (Required)
Before defining any schema, check if primitives exist. See .claude/rules/zod-primitives.md for full spec.
Import Primitives First
import {
UserIdSchema,
OrganizationIdSchema,
DeviceIdSchema,
EmailSchema,
UsernameSchema,
NameSchema,
DescriptionSchema,
} from '@/schemas/primitives';
Available Primitives
Identity (primitives/identity.ts):
UserIdSchema, OrganizationIdSchema, DeviceIdSchema, AlarmIdSchema, SessionIdSchema
- All use
.brand<'TypeName'>() for compile-time ID safety
User Fields (primitives/user-fields.ts):
UsernameSchema - 3-30 chars, alphanumeric + _-
EmailSchema - valid email, lowercase transform
PasswordSchema - 12+ chars, complexity requirements
DisplayNameSchema - 1-100 chars, trimmed
PhoneSchema - E.164 format, optional
Constraints (primitives/constraints.ts):
NameSchema - 1-255 chars, trimmed
ShortNameSchema - 1-100 chars, trimmed
DescriptionSchema - max 2000, optional
SlugSchema - lowercase alphanumeric with hyphens
UrlSchema, HttpsUrlSchema
TimestampSchema - coerced date
When to Create New Primitives
- Same pattern appears 2+ times across schemas
- Field represents common concept (IDs, names, emails, URLs)
- Validation logic is non-trivial (regex, transforms)
Add to primitives/ with .describe(), export from barrel.
Schema Patterns
Entity Schema
import { z } from 'zod';
import {
DeviceIdSchema,
OrganizationIdSchema,
NameSchema,
DescriptionSchema,
TimestampSchema,
} from '@/schemas/primitives';
export const DeviceSchema = z.object({
id: DeviceIdSchema,
organizationId: OrganizationIdSchema,
name: NameSchema,
description: DescriptionSchema,
type: z.enum(['sensor', 'gateway', 'actuator']).describe('Device category'),
status: z.enum(['active', 'inactive', 'offline', 'disabled']).describe('Current device status'),
metadata: z.record(z.unknown()).optional().describe('Custom device attributes'),
lastSeenAt: z.iso.datetime().nullable().describe('Last communication timestamp'),
createdAt: TimestampSchema,
updatedAt: TimestampSchema,
});
export type Device = z.infer<typeof DeviceSchema>;
export const CreateDeviceSchema = DeviceSchema.omit({
id: true,
organizationId: true,
lastSeenAt: true,
createdAt: true,
updatedAt: true,
});
export type CreateDevice = z.infer<typeof CreateDeviceSchema>;
export const UpdateDeviceSchema = CreateDeviceSchema.partial();
export type UpdateDevice = z.infer<typeof UpdateDeviceSchema>;
export const DeviceResponseSchema = DeviceSchema;
export const DeviceListResponseSchema = z.object({
data: z.array(DeviceSchema),
pagination: z.object({
nextCursor: z.string().nullable(),
prevCursor: z.string().nullable(),
hasMore: z.boolean(),
total: z.number().int().optional(),
}),
});
Request Schemas
export const DeviceParamsSchema = z.object({
id: z.uuid().describe('Device ID'),
});
export const OrganizationDeviceParamsSchema = z.object({
organizationId: z.uuid().describe('Organization ID'),
deviceId: z.uuid().describe('Device ID'),
});
export const PaginationQuerySchema = z.object({
limit: z.coerce.number().min(1).max(100).default(20).describe('Items per page'),
cursor: z.string().optional().describe('Pagination cursor'),
sort: z.enum(['asc', 'desc']).default('desc').describe('Sort direction'),
});
export const DeviceFilterQuerySchema = PaginationQuerySchema.extend({
status: z.enum(['active', 'inactive', 'offline', 'disabled']).optional().describe('Filter by status'),
type: z.enum(['sensor', 'gateway', 'actuator']).optional().describe('Filter by type'),
search: z.string().max(100).optional().describe('Search by name'),
});
Error Schemas
export const ErrorResponseSchema = z.object({
error: z.object({
code: z.string().describe('Machine-readable error code'),
message: z.string().describe('Human-readable error message'),
details: z.array(z.object({
field: z.string().optional().describe('Field that caused the error'),
message: z.string().describe('Detail message'),
code: z.string().optional().describe('Detail error code'),
})).optional().describe('Validation error details'),
requestId: z.uuid().optional().describe('Request ID for support'),
timestamp: z.iso.datetime().optional().describe('Error timestamp'),
}),
});
export const NotFoundErrorSchema = z.object({
error: z.object({
code: z.literal('NOT_FOUND'),
message: z.string(),
requestId: z.uuid().optional(),
}),
});
export const ValidationErrorSchema = z.object({
error: z.object({
code: z.literal('VALIDATION_ERROR'),
message: z.string(),
details: z.array(z.object({
field: z.string(),
message: z.string(),
})),
requestId: z.uuid().optional(),
}),
});
export const UnauthorizedErrorSchema = z.object({
error: z.object({
code: z.literal('UNAUTHORIZED'),
message: z.string(),
requestId: z.uuid().optional(),
}),
});
Telemetry Schemas
export const TelemetryPointSchema = z.object({
timestamp: z.iso.datetime().describe('Measurement timestamp'),
values: z.record(z.number()).describe('Metric name to value mapping'),
});
export const TelemetrySubmitSchema = z.object({
deviceId: z.uuid().describe('Source device ID'),
points: z.array(TelemetryPointSchema).min(1).max(1000).describe('Data points'),
});
export const TelemetryQuerySchema = z.object({
deviceId: z.uuid().describe('Device to query'),
metrics: z.array(z.string()).optional().describe('Specific metrics to retrieve'),
startTime: z.iso.datetime().describe('Query start time'),
endTime: z.iso.datetime().describe('Query end time'),
aggregation: z.enum(['none', 'avg', 'min', 'max', 'sum']).default('none'),
interval: z.enum(['1m', '5m', '15m', '1h', '1d']).optional(),
});
Config Schema Pattern
export const ConfigSchema = z.object({
port: z.coerce.number().min(1).max(65535).default(3000),
host: z.string().default('0.0.0.0'),
nodeEnv: z.enum(['development', 'production', 'test']).default('development'),
databaseUrl: z.url().describe('PostgreSQL connection string'),
jwtSecret: z.string().min(32).describe('JWT signing secret'),
accessTokenExpiryMinutes: z.coerce.number().default(15),
refreshTokenExpiryDays: z.coerce.number().default(7),
maxRetries: z.coerce.number().min(1).max(10).default(3),
requestTimeoutMs: z.coerce.number().default(30000),
deviceQuotaLimit: z.coerce.number().default(100),
mqttBrokerUrl: z.url().optional(),
awsIotEndpoint: z.string().optional(),
});
export type Config = z.infer<typeof ConfigSchema>;
export const config = ConfigSchema.parse({
port: process.env.PORT,
host: process.env.HOST,
nodeEnv: process.env.NODE_ENV,
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
});
Zod 4 Features to Use
Refinements
const PasswordSchema = z.string()
.min(12, 'Password must be at least 12 characters')
.refine((val) => /[A-Z]/.test(val), 'Must contain uppercase')
.refine((val) => /[a-z]/.test(val), 'Must contain lowercase')
.refine((val) => /[0-9]/.test(val), 'Must contain number')
.refine((val) => /[^A-Za-z0-9]/.test(val), 'Must contain special character');
Transforms
const DateStringSchema = z.string()
.datetime()
.transform((val) => new Date(val));
const TrimmedStringSchema = z.string()
.transform((val) => val.trim());
Discriminated Unions
const EventSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('device.created'),
payload: DeviceSchema,
}),
z.object({
type: z.literal('device.updated'),
payload: z.object({
id: z.uuid(),
changes: UpdateDeviceSchema,
}),
}),
z.object({
type: z.literal('device.deleted'),
payload: z.object({
id: z.uuid(),
}),
}),
]);
Coercion
const QuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
enabled: z.coerce.boolean().default(true),
});
Number Validators (Zod 4)
z.number()
z.int()
z.int32()
z.uint32()
z.int64()
z.float32()
z.float64()
const PortSchema = z.uint32().max(65535).describe('Port number');
const LatitudeSchema = z.float64().min(-90).max(90);
Error Handling
.safeParse() vs .parse()
const result = CreateDeviceSchema.safeParse(request.body);
if (!result.success) {
const formatted = z.flattenError(result.error);
return reply.status(400).send({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid request',
details: Object.entries(formatted.fieldErrors).map(([field, msgs]) => ({
field,
message: msgs?.[0] ?? 'Invalid value',
})),
},
});
}
const validatedData = result.data;
const config = ConfigSchema.parse(process.env);
ZodError Structure
{
code: 'too_small',
path: ['devices', 0, 'name'],
message: 'Name required',
minimum: 1,
inclusive: true,
}
'invalid_type'
'too_small'
'too_big'
'invalid_string'
'custom'
Async Validation
const UniqueEmailSchema = z.email().refine(
async (email) => {
const exists = await userRepo.existsByEmail(email);
return !exists;
},
{ message: 'Email already registered' }
);
const result = await UniqueEmailSchema.safeParseAsync(input);
Checklist
Migration from Zod 3
If converting existing Zod 3 code:
Codemod (Automated)
npx zod-v3-to-v4
Review automated changes carefully - some may need manual adjustment.
Quick Reference
| Zod 3 | Zod 4 |
|---|
{ message: '...' } | { error: '...' } |
{ invalid_type_error: '...' } | { error: (ctx) => ... } |
z.string().email() | z.email() (preferred) |
z.string().uuid() | z.uuid() (preferred) |
z.url() | z.url() (preferred) |
.strict() | z.strictObject() |
.passthrough() | z.looseObject() |
z.record(valueSchema) | z.record(keySchema, valueSchema) |
.args().returns() | { input: [...], output: ... } |
error.flatten() | z.flattenError(error) |
Official Resources