Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:46:06 +08:00
commit 61aa7d532c
10 changed files with 1723 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
{
"name": "api-contracts-and-zod-validation",
"description": "Generate Zod schemas and TypeScript types for forms, API routes, and Server Actions with runtime validation. Use this skill when creating API contracts, validating request/response payloads, generating form schemas, adding input validation to Server Actions or route handlers, or ensuring type safety across client-server boundaries. Trigger terms include zod, schema, validation, API contract, form validation, type inference, runtime validation, parse, safeParse, input validation, request validati",
"version": "1.0.0",
"author": {
"name": "Hope Overture",
"email": "support@worldbuilding-app-skills.dev"
},
"skills": [
"./skills"
]
}

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# api-contracts-and-zod-validation
Generate Zod schemas and TypeScript types for forms, API routes, and Server Actions with runtime validation. Use this skill when creating API contracts, validating request/response payloads, generating form schemas, adding input validation to Server Actions or route handlers, or ensuring type safety across client-server boundaries. Trigger terms include zod, schema, validation, API contract, form validation, type inference, runtime validation, parse, safeParse, input validation, request validati

69
plugin.lock.json Normal file
View File

@@ -0,0 +1,69 @@
{
"$schema": "internal://schemas/plugin.lock.v1.json",
"pluginId": "gh:hopeoverture/worldbuilding-app-skills:plugins/api-contracts-and-zod-validation",
"normalized": {
"repo": null,
"ref": "refs/tags/v20251128.0",
"commit": "77a0f2121742d8e4932d0e2aa143a1c97aa17596",
"treeHash": "58555bca51fcdfb92b21fedc000b43fd5d48f772e6caadbbf0ede0932b1725d2",
"generatedAt": "2025-11-28T10:17:31.022390Z",
"toolVersion": "publish_plugins.py@0.2.0"
},
"origin": {
"remote": "git@github.com:zhongweili/42plugin-data.git",
"branch": "master",
"commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390",
"repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data"
},
"manifest": {
"name": "api-contracts-and-zod-validation",
"description": "Generate Zod schemas and TypeScript types for forms, API routes, and Server Actions with runtime validation. Use this skill when creating API contracts, validating request/response payloads, generating form schemas, adding input validation to Server Actions or route handlers, or ensuring type safety across client-server boundaries. Trigger terms include zod, schema, validation, API contract, form validation, type inference, runtime validation, parse, safeParse, input validation, request validati",
"version": "1.0.0"
},
"content": {
"files": [
{
"path": "README.md",
"sha256": "d0a0dc2863c094f3005666a3349526352174ef87dfc5280a9489b5069d395622"
},
{
"path": ".claude-plugin/plugin.json",
"sha256": "74acb295a7b756b01a5654aebc4c5d02e9f17da4ea9b589fa72d9b2539b69591"
},
{
"path": "skills/api-contracts-and-zod-validation/SKILL.md",
"sha256": "e4add77daf6cb22a225c9504431afe0d9c4e57ed12f4d5ccf645f38197cf5cd3"
},
{
"path": "skills/api-contracts-and-zod-validation/references/zod_patterns.md",
"sha256": "e5a4ad84e445e20b59cf22eb81036f310e68fa8c35c487c0e13cd147f335da32"
},
{
"path": "skills/api-contracts-and-zod-validation/scripts/generate_zod_schema.py",
"sha256": "247dcc77226a6b9d17e3f80d94de29e93558526a309ccc5f5070b9d9fbc1e7a2"
},
{
"path": "skills/api-contracts-and-zod-validation/assets/schema_templates/form_schema_template.ts",
"sha256": "f552d06a4b68ddfe50e5a81ccc8d8aff5cf5fe8add8abb0d92abb671a876b163"
},
{
"path": "skills/api-contracts-and-zod-validation/assets/schema_templates/api_route_schema_template.ts",
"sha256": "1c244e70d23e97de636e90c677175962eae3767d3f80f3900397a4ece4ceeb78"
},
{
"path": "skills/api-contracts-and-zod-validation/assets/schema_templates/entity_schema_template.ts",
"sha256": "2f18355726177380fa66e4898474e752c39b84deb895120abb9d176393969cba"
},
{
"path": "skills/api-contracts-and-zod-validation/assets/schema_templates/server_action_schema_template.ts",
"sha256": "4db03c84bd401b95dca83f20cb7a281cdb6325541fa2659d5a2b387199347da0"
}
],
"dirSha256": "58555bca51fcdfb92b21fedc000b43fd5d48f772e6caadbbf0ede0932b1725d2"
},
"security": {
"scannedAt": null,
"scannerVersion": null,
"flags": []
}
}

View File

@@ -0,0 +1,124 @@
---
name: api-contracts-and-zod-validation
description: Generate Zod schemas and TypeScript types for forms, API routes, and Server Actions with runtime validation. Use this skill when creating API contracts, validating request/response payloads, generating form schemas, adding input validation to Server Actions or route handlers, or ensuring type safety across client-server boundaries. Trigger terms include zod, schema, validation, API contract, form validation, type inference, runtime validation, parse, safeParse, input validation, request validation, Server Action validation.
---
# API Contracts and Zod Validation
Generate Zod schemas with TypeScript type inference for forms, API routes, and Server Actions. Validate inputs in Server Actions or route handlers with runtime type checking.
## Core Capabilities
### 1. Generate Zod Schemas from TypeScript Types
When creating API contracts or form validations:
- Analyze existing TypeScript interfaces or types
- Generate equivalent Zod schemas with proper validation rules
- Ensure bidirectional type compatibility (Zod -> TypeScript)
- Use `z.infer<typeof schema>` for automatic type inference
### 2. Form Validation Schemas
To create form validation with Zod:
- Generate schemas for React Hook Form, Formik, or native forms
- Include field-level validation rules (min/max length, regex, custom validators)
- Support nested objects, arrays, and complex data structures
- Provide helpful error messages for validation failures
- Use `references/zod_patterns.md` for common validation patterns
### 3. API Route and Server Action Validation
To add validation to API endpoints or Server Actions:
- Wrap handler logic with schema validation using `.parse()` or `.safeParse()`
- Return typed errors for validation failures
- Generate request/response schemas for API contracts
- Validate query parameters, body payloads, and route parameters
- Ensure type safety between client and server code
### 4. Schema Generation Script
Use `scripts/generate_zod_schema.py` to automate schema generation:
```bash
python scripts/generate_zod_schema.py --input types/entities.ts --output schemas/entities.ts
```
The script:
- Parses TypeScript interfaces/types
- Generates Zod schemas with appropriate validators
- Preserves JSDoc comments as descriptions
- Handles common patterns (optional fields, unions, enums)
## Resource Files
### scripts/generate_zod_schema.py
Automated TypeScript-to-Zod schema generator. Parses TypeScript AST and generates equivalent Zod schemas.
### references/zod_patterns.md
Common Zod validation patterns including:
- String validation (email, URL, UUID, custom regex)
- Number constraints (min, max, positive, integer)
- Date validation and transformation
- Array and tuple validation
- Object shape validation
- Union and discriminated unions
- Optional and nullable fields
- Custom refinements and transforms
- Error message customization
### assets/schema_templates/
Pre-built Zod schema templates:
- `form_schema_template.ts` - Basic form validation
- `api_route_schema_template.ts` - API route request/response
- `server_action_schema_template.ts` - Server Action input/output
- `entity_schema_template.ts` - Database entity validation
## Usage Workflow
### For New API Routes
1. Define request/response TypeScript types
2. Generate Zod schemas using `scripts/generate_zod_schema.py` or templates
3. Add validation middleware to route handler
4. Return typed validation errors on failure
### For Server Actions
1. Define input parameters as TypeScript types
2. Create Zod schema for validation
3. Use `.safeParse()` at the beginning of the action
4. Return validation errors in action result
5. Handle errors on the client side
### For Forms
1. Define form fields as TypeScript interface
2. Generate Zod schema with field validators
3. Integrate with form library (React Hook Form recommended)
4. Display validation errors inline
## Best Practices
- Always use `.safeParse()` instead of `.parse()` to avoid throwing exceptions
- Provide clear, user-friendly error messages
- Validate early (at API boundary or Server Action entry)
- Keep schemas colocated with routes/actions for maintainability
- Use `z.infer<typeof schema>` for automatic TypeScript types
- Document validation rules in schema comments
- Test edge cases (empty values, invalid formats, boundary conditions)
## Integration with Worldbuilding App
Common use cases for worldbuilding applications:
- **Entity creation forms**: Validate character, location, faction data
- **Relationship APIs**: Ensure valid entity IDs and relationship types
- **Timeline events**: Validate dates, ordering, and event data
- **Search/filter endpoints**: Validate query parameters and filters
- **Bulk operations**: Validate arrays of entities or updates
- **Import/export**: Validate file formats and data structure
Consult `references/zod_patterns.md` for specific validation patterns applicable to worldbuilding entities.

View File

@@ -0,0 +1,245 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
/**
* API Route validation schema template
*
* Usage:
* 1. Define request and response schemas
* 2. Validate incoming requests with safeParse
* 3. Return typed errors for invalid requests
*/
// Request schema
const requestSchema = z.object({
query: z.string().min(1, 'Query is required'),
filters: z.object({
type: z.array(z.string()).optional(),
tags: z.array(z.string()).optional(),
dateRange: z.object({
start: z.string().datetime().optional(),
end: z.string().datetime().optional(),
}).optional(),
}).optional(),
pagination: z.object({
page: z.number().int().positive().default(1),
limit: z.number().int().min(1).max(100).default(10),
}).optional(),
});
// Response schema
const responseSchema = z.object({
success: z.boolean(),
data: z.any().optional(),
error: z.string().optional(),
details: z.any().optional(),
});
// Types
type RequestBody = z.infer<typeof requestSchema>;
type ResponseBody = z.infer<typeof responseSchema>;
/**
* POST endpoint with validation
*/
export async function POST(request: NextRequest): Promise<NextResponse<ResponseBody>> {
try {
// Parse request body
const body = await request.json();
// Validate request
const result = requestSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{
success: false,
error: 'Invalid request body',
details: result.error.format(),
},
{ status: 400 }
);
}
// Type-safe validated data
const { query, filters, pagination } = result.data;
// Perform business logic
const data = await processRequest(query, filters, pagination);
return NextResponse.json({
success: true,
data,
});
} catch (error) {
return NextResponse.json(
{
success: false,
error: 'Internal server error',
},
{ status: 500 }
);
}
}
async function processRequest(
query: string,
filters?: RequestBody['filters'],
pagination?: RequestBody['pagination']
) {
// Implement your business logic here
return { results: [], total: 0 };
}
/**
* GET endpoint with query parameter validation
*/
const queryParamsSchema = z.object({
id: z.string().uuid('Invalid ID format'),
includeRelated: z.coerce.boolean().optional(),
});
export async function GET(request: NextRequest): Promise<NextResponse<ResponseBody>> {
// Parse query parameters
const searchParams = request.nextUrl.searchParams;
const queryParams = {
id: searchParams.get('id'),
includeRelated: searchParams.get('includeRelated'),
};
// Validate query parameters
const result = queryParamsSchema.safeParse(queryParams);
if (!result.success) {
return NextResponse.json(
{
success: false,
error: 'Invalid query parameters',
details: result.error.format(),
},
{ status: 400 }
);
}
const { id, includeRelated } = result.data;
// Fetch data
const data = await fetchData(id, includeRelated);
return NextResponse.json({
success: true,
data,
});
}
async function fetchData(id: string, includeRelated?: boolean) {
// Implement your data fetching logic here
return { id, includeRelated };
}
/**
* PATCH endpoint with partial update validation
*/
const updateSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional(),
tags: z.array(z.string()).max(10).optional(),
status: z.enum(['active', 'inactive', 'archived']).optional(),
}).refine(
(data) => Object.keys(data).length > 0,
{ message: 'At least one field must be provided for update' }
);
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
): Promise<NextResponse<ResponseBody>> {
// Validate ID from route params
const idResult = z.string().uuid().safeParse(params.id);
if (!idResult.success) {
return NextResponse.json(
{
success: false,
error: 'Invalid ID format',
},
{ status: 400 }
);
}
// Parse and validate request body
const body = await request.json();
const result = updateSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{
success: false,
error: 'Invalid update data',
details: result.error.format(),
},
{ status: 400 }
);
}
const id = idResult.data;
const updates = result.data;
// Perform update
const data = await updateData(id, updates);
return NextResponse.json({
success: true,
data,
});
}
async function updateData(id: string, updates: z.infer<typeof updateSchema>) {
// Implement your update logic here
return { id, ...updates };
}
// Example: Middleware with validation
export function validateRequest<T>(schema: z.ZodSchema<T>) {
return async (request: NextRequest): Promise<{ data?: T; error?: ResponseBody }> => {
try {
const body = await request.json();
const result = schema.safeParse(body);
if (!result.success) {
return {
error: {
success: false,
error: 'Validation failed',
details: result.error.format(),
},
};
}
return { data: result.data };
} catch {
return {
error: {
success: false,
error: 'Invalid JSON body',
},
};
}
};
}
// Usage of middleware:
/*
export async function POST(request: NextRequest) {
const { data, error } = await validateRequest(requestSchema)(request);
if (error) {
return NextResponse.json(error, { status: 400 });
}
// Use validated data
console.log(data);
return NextResponse.json({ success: true });
}
*/

View File

@@ -0,0 +1,210 @@
import { z } from 'zod';
/**
* Entity validation schema template for worldbuilding applications
*
* Common schemas for worldbuilding entities like characters, locations, items, etc.
*/
// Base entity schema (shared fields across all entities)
const baseEntitySchema = z.object({
id: z.string().uuid(),
name: z.string().min(1, 'Name is required').max(200),
description: z.string().max(5000).optional(),
tags: z.array(z.string()).max(20),
metadata: z.object({
createdAt: z.date(),
updatedAt: z.date(),
createdBy: z.string(),
}),
});
// Entity types enum
export const entityTypeSchema = z.enum([
'character',
'location',
'item',
'faction',
'event',
'concept',
]);
export type EntityType = z.infer<typeof entityTypeSchema>;
// Character entity schema
export const characterSchema = baseEntitySchema.extend({
type: z.literal('character'),
attributes: z.object({
age: z.number().int().nonnegative().optional(),
gender: z.string().optional(),
species: z.string().optional(),
occupation: z.string().optional(),
birthplace: z.string().uuid().optional(), // Reference to location entity
}),
relationships: z.array(
z.object({
targetId: z.string().uuid(),
type: z.string(),
strength: z.number().min(0).max(1).optional(),
})
),
});
export type Character = z.infer<typeof characterSchema>;
// Location entity schema
export const locationSchema = baseEntitySchema.extend({
type: z.literal('location'),
attributes: z.object({
locationType: z.enum(['city', 'region', 'country', 'landmark', 'building', 'other']),
population: z.number().int().nonnegative().optional(),
parentLocation: z.string().uuid().optional(), // Reference to parent location
coordinates: z.object({
x: z.number(),
y: z.number(),
}).optional(),
}),
});
export type Location = z.infer<typeof locationSchema>;
// Item entity schema
export const itemSchema = baseEntitySchema.extend({
type: z.literal('item'),
attributes: z.object({
itemType: z.enum(['weapon', 'armor', 'tool', 'artifact', 'consumable', 'other']),
rarity: z.enum(['common', 'uncommon', 'rare', 'legendary']).optional(),
owner: z.string().uuid().optional(), // Reference to character entity
location: z.string().uuid().optional(), // Reference to location entity
}),
});
export type Item = z.infer<typeof itemSchema>;
// Faction/Organization entity schema
export const factionSchema = baseEntitySchema.extend({
type: z.literal('faction'),
attributes: z.object({
factionType: z.enum(['guild', 'government', 'religion', 'military', 'criminal', 'other']),
size: z.enum(['small', 'medium', 'large', 'massive']).optional(),
headquarters: z.string().uuid().optional(), // Reference to location entity
leader: z.string().uuid().optional(), // Reference to character entity
founded: z.object({
year: z.number().int(),
month: z.number().int().min(1).max(12).optional(),
day: z.number().int().min(1).max(31).optional(),
}).optional(),
}),
members: z.array(
z.object({
characterId: z.string().uuid(),
role: z.string(),
joinDate: z.date().optional(),
})
),
});
export type Faction = z.infer<typeof factionSchema>;
// Event entity schema
export const eventSchema = baseEntitySchema.extend({
type: z.literal('event'),
attributes: z.object({
eventType: z.enum(['battle', 'meeting', 'ceremony', 'discovery', 'disaster', 'other']),
date: z.object({
year: z.number().int(),
month: z.number().int().min(1).max(12).optional(),
day: z.number().int().min(1).max(31).optional(),
}),
location: z.string().uuid().optional(), // Reference to location entity
participants: z.array(z.string().uuid()), // References to character entities
}),
}).refine(
(data) => !data.attributes.date.day || data.attributes.date.month,
{ message: 'Month is required when day is specified', path: ['attributes', 'date', 'month'] }
);
export type Event = z.infer<typeof eventSchema>;
// Concept entity schema (abstract ideas, magic systems, technologies, etc.)
export const conceptSchema = baseEntitySchema.extend({
type: z.literal('concept'),
attributes: z.object({
conceptType: z.enum(['magic', 'technology', 'philosophy', 'language', 'custom']),
relatedEntities: z.array(z.string().uuid()), // References to any entity
}),
});
export type Concept = z.infer<typeof conceptSchema>;
// Union of all entity types
export const entitySchema = z.discriminatedUnion('type', [
characterSchema,
locationSchema,
itemSchema,
factionSchema,
eventSchema,
conceptSchema,
]);
export type Entity = z.infer<typeof entitySchema>;
// Relationship schema
export const relationshipSchema = z.object({
id: z.string().uuid(),
sourceId: z.string().uuid(),
targetId: z.string().uuid(),
type: z.string().min(1).max(50),
description: z.string().max(500).optional(),
strength: z.number().min(0).max(1).optional(),
metadata: z.record(z.string(), z.any()).optional(),
createdAt: z.date(),
updatedAt: z.date(),
}).refine(
(data) => data.sourceId !== data.targetId,
{ message: 'Source and target must be different entities', path: ['targetId'] }
);
export type Relationship = z.infer<typeof relationshipSchema>;
// Create entity input schema (without auto-generated fields)
export const createEntityInputSchema = z.object({
type: entityTypeSchema,
name: z.string().min(1).max(200),
description: z.string().max(5000).optional(),
tags: z.array(z.string()).max(20).default([]),
attributes: z.record(z.string(), z.any()),
});
export type CreateEntityInput = z.infer<typeof createEntityInputSchema>;
// Update entity input schema (partial)
export const updateEntityInputSchema = z.object({
name: z.string().min(1).max(200).optional(),
description: z.string().max(5000).optional(),
tags: z.array(z.string()).max(20).optional(),
attributes: z.record(z.string(), z.any()).optional(),
}).refine(
(data) => Object.keys(data).length > 0,
{ message: 'At least one field must be provided for update' }
);
export type UpdateEntityInput = z.infer<typeof updateEntityInputSchema>;
// Query/filter schema for entity search
export const entityQuerySchema = z.object({
query: z.string().optional(),
types: z.array(entityTypeSchema).optional(),
tags: z.array(z.string()).optional(),
createdAfter: z.date().optional(),
createdBefore: z.date().optional(),
createdBy: z.string().optional(),
pagination: z.object({
page: z.number().int().positive().default(1),
limit: z.number().int().min(1).max(100).default(10),
}).optional(),
sortBy: z.enum(['name', 'createdAt', 'updatedAt']).default('createdAt'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
});
export type EntityQuery = z.infer<typeof entityQuerySchema>;

View File

@@ -0,0 +1,81 @@
import { z } from 'zod';
/**
* Form validation schema template
*
* Usage:
* 1. Customize the schema fields for your form
* 2. Use with React Hook Form or other form libraries
* 3. Extract TypeScript type with z.infer<typeof formSchema>
*/
export const formSchema = z.object({
// Text input
name: z.string()
.min(1, 'Name is required')
.max(100, 'Name must be 100 characters or less'),
// Email input
email: z.string()
.email('Invalid email address'),
// Optional text field
description: z.string()
.max(500, 'Description must be 500 characters or less')
.optional(),
// Number input
age: z.coerce.number()
.int('Age must be a whole number')
.positive('Age must be positive')
.optional(),
// Select/dropdown
category: z.enum(['option1', 'option2', 'option3'], {
required_error: 'Please select a category',
}),
// Checkbox
agreedToTerms: z.boolean()
.refine((val) => val === true, {
message: 'You must agree to the terms',
}),
// Multi-select
tags: z.array(z.string())
.min(1, 'At least one tag is required')
.max(10, 'Maximum 10 tags allowed'),
// Date input
birthDate: z.coerce.date()
.max(new Date(), 'Birth date cannot be in the future')
.optional(),
});
// Infer TypeScript type from schema
export type FormData = z.infer<typeof formSchema>;
// Example: React Hook Form integration
/*
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
function MyForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(formSchema),
});
const onSubmit = (data: FormData) => {
console.log('Valid form data:', data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
<button type="submit">Submit</button>
</form>
);
}
*/

View File

@@ -0,0 +1,131 @@
'use server';
import { z } from 'zod';
/**
* Server Action validation schema template
*
* Usage:
* 1. Define input schema for Server Action parameters
* 2. Validate with safeParse at the beginning of the action
* 3. Return validation errors to the client
*/
// Input schema
const actionInputSchema = z.object({
id: z.string().uuid('Invalid ID format'),
name: z.string().min(1, 'Name is required').max(100),
type: z.enum(['type1', 'type2', 'type3']),
metadata: z.record(z.string(), z.any()).optional(),
});
// Output schema (optional, for type safety)
const actionOutputSchema = z.object({
success: z.boolean(),
data: z.any().optional(),
errors: z.record(z.array(z.string())).optional(),
});
// Types
type ActionInput = z.infer<typeof actionInputSchema>;
type ActionOutput = z.infer<typeof actionOutputSchema>;
/**
* Example Server Action with validation
*/
export async function myServerAction(input: unknown): Promise<ActionOutput> {
// Validate input
const result = actionInputSchema.safeParse(input);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
};
}
// Type-safe validated data
const { id, name, type, metadata } = result.data;
try {
// Perform server-side logic
// Example: database operation, API call, etc.
const data = await performAction(id, name, type, metadata);
return {
success: true,
data,
};
} catch (error) {
return {
success: false,
errors: {
_general: ['An unexpected error occurred'],
},
};
}
}
async function performAction(
id: string,
name: string,
type: string,
metadata?: Record<string, any>
) {
// Implement your server-side logic here
return { id, name, type, metadata };
}
// Example: FormData-based Server Action
export async function formDataAction(formData: FormData): Promise<ActionOutput> {
const rawData = {
id: formData.get('id'),
name: formData.get('name'),
type: formData.get('type'),
};
const result = actionInputSchema.safeParse(rawData);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
};
}
// Process validated data
const { id, name, type } = result.data;
// ... rest of implementation
return { success: true, data: { id, name, type } };
}
// Example: Client-side usage
/*
'use client';
import { myServerAction } from './actions';
import { useState } from 'react';
function MyComponent() {
const [errors, setErrors] = useState<Record<string, string[]>>({});
async function handleSubmit(data: any) {
const result = await myServerAction(data);
if (!result.success) {
setErrors(result.errors || {});
} else {
console.log('Success:', result.data);
setErrors({});
}
}
return (
<div>
{errors.name && <span>{errors.name[0]}</span>}
</div>
);
}
*/

View File

@@ -0,0 +1,524 @@
# Zod Validation Patterns
Common Zod validation patterns for API contracts, forms, and Server Actions.
## String Validation
### Basic String Constraints
```typescript
import { z } from 'zod';
// Required string
const requiredString = z.string();
// Optional string
const optionalString = z.string().optional();
// String with length constraints
const username = z.string().min(3).max(20);
// Non-empty string
const nonEmpty = z.string().min(1);
// String with default value
const withDefault = z.string().default('default value');
```
### Common String Formats
```typescript
// Email validation
const email = z.string().email();
// URL validation
const url = z.string().url();
// UUID validation
const uuid = z.string().uuid();
// Custom regex pattern
const phoneNumber = z.string().regex(/^\+?[1-9]\d{1,14}$/);
// Alphanumeric only
const alphanumeric = z.string().regex(/^[a-zA-Z0-9]+$/);
// Trim whitespace
const trimmed = z.string().trim();
// Lowercase transformation
const lowercase = z.string().toLowerCase();
```
### Advanced String Validation
```typescript
// Custom refinement
const strongPassword = z.string()
.min(8)
.refine(
(val) => /[A-Z]/.test(val) && /[a-z]/.test(val) && /[0-9]/.test(val),
{ message: 'Password must contain uppercase, lowercase, and number' }
);
// Multiple validations with custom messages
const entityName = z.string()
.min(1, 'Name is required')
.max(100, 'Name must be 100 characters or less')
.regex(/^[a-zA-Z0-9\s-]+$/, 'Name can only contain letters, numbers, spaces, and hyphens');
```
## Number Validation
```typescript
// Basic number
const age = z.number();
// Integer only
const count = z.number().int();
// Positive number
const positive = z.number().positive();
// Non-negative (>= 0)
const nonNegative = z.number().nonnegative();
// Min/max constraints
const percentage = z.number().min(0).max(100);
// Multiple of
const evenNumber = z.number().multipleOf(2);
// Finite number (not Infinity or NaN)
const finite = z.number().finite();
// Convert string to number
const stringToNumber = z.coerce.number();
```
## Boolean Validation
```typescript
// Basic boolean
const isActive = z.boolean();
// Optional boolean with default
const isPublic = z.boolean().default(false);
// Coerce to boolean (accepts "true", "false", 1, 0)
const coercedBoolean = z.coerce.boolean();
```
## Date Validation
```typescript
// Basic date
const createdAt = z.date();
// Date with min/max constraints
const futureDate = z.date().min(new Date());
const pastDate = z.date().max(new Date());
// Date range
const dateInRange = z.date()
.min(new Date('2020-01-01'))
.max(new Date('2030-12-31'));
// Convert string to date
const stringToDate = z.coerce.date();
// ISO date string
const isoDate = z.string().datetime();
```
## Array Validation
```typescript
// Array of strings
const tags = z.array(z.string());
// Array with length constraints
const limitedTags = z.array(z.string()).min(1).max(10);
// Non-empty array
const nonEmptyArray = z.array(z.string()).nonempty();
// Array with unique items
const uniqueTags = z.array(z.string())
.refine((items) => new Set(items).size === items.length, {
message: 'Tags must be unique'
});
// Tuple (fixed-length array with different types)
const coordinate = z.tuple([z.number(), z.number()]);
```
## Object Validation
```typescript
// Basic object shape
const user = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
age: z.number().int().positive().optional(),
});
// Nested objects
const entity = z.object({
id: z.string(),
name: z.string(),
metadata: z.object({
createdBy: z.string(),
tags: z.array(z.string()),
}),
});
// Partial object (all fields optional)
const partialUser = user.partial();
// Pick specific fields
const userCredentials = user.pick({ email: true, password: true });
// Omit specific fields
const publicUser = user.omit({ password: true });
// Extend object
const extendedUser = user.extend({
role: z.string(),
});
// Merge objects
const merged = user.merge(z.object({ role: z.string() }));
```
## Union and Intersection Types
```typescript
// Union (one of multiple types)
const stringOrNumber = z.union([z.string(), z.number()]);
// Discriminated union
const result = z.discriminatedUnion('status', [
z.object({ status: z.literal('success'), data: z.any() }),
z.object({ status: z.literal('error'), error: z.string() }),
]);
// Intersection (combines multiple types)
const timestamped = z.object({
createdAt: z.date(),
updatedAt: z.date(),
});
const userWithTimestamps = z.intersection(user, timestamped);
```
## Enum and Literal Types
```typescript
// Enum
const roleEnum = z.enum(['admin', 'user', 'guest']);
// Literal
const statusLiteral = z.literal('active');
// Multiple literals (union)
const status = z.union([
z.literal('active'),
z.literal('inactive'),
z.literal('pending'),
]);
// Native enum support
enum Role {
Admin = 'admin',
User = 'user',
Guest = 'guest',
}
const nativeEnum = z.nativeEnum(Role);
```
## Optional and Nullable
```typescript
// Optional (can be undefined)
const optional = z.string().optional();
// Nullable (can be null)
const nullable = z.string().nullable();
// Both optional and nullable
const optionalNullable = z.string().optional().nullable();
// Nullish (undefined or null)
const nullish = z.string().nullish();
```
## Custom Error Messages
```typescript
// Field-level messages
const username = z.string({
required_error: 'Username is required',
invalid_type_error: 'Username must be a string',
});
// Validation-level messages
const password = z.string()
.min(8, { message: 'Password must be at least 8 characters' })
.max(100, { message: 'Password is too long' });
// Custom refinement with message
const ageValidation = z.number()
.refine((val) => val >= 18, {
message: 'Must be 18 or older',
});
```
## Transformations
```typescript
// Transform to different type
const numberFromString = z.string().transform((val) => parseInt(val, 10));
// Preprocessing
const trimmedString = z.preprocess(
(val) => typeof val === 'string' ? val.trim() : val,
z.string()
);
// Chain transforms
const normalized = z.string()
.trim()
.toLowerCase()
.transform((val) => val.replace(/\s+/g, '-'));
```
## Advanced Patterns
### Conditional Validation
```typescript
// Dependent fields
const formSchema = z.object({
hasAddress: z.boolean(),
address: z.string().optional(),
}).refine(
(data) => !data.hasAddress || (data.hasAddress && data.address),
{ message: 'Address is required when hasAddress is true', path: ['address'] }
);
```
### Record/Map Validation
```typescript
// Record with string keys
const metadata = z.record(z.string(), z.any());
// Record with specific value type
const scores = z.record(z.string(), z.number());
// Map
const map = z.map(z.string(), z.number());
```
### Recursive Types
```typescript
// Self-referencing type (tree structure)
interface Category {
id: string;
name: string;
children?: Category[];
}
const categorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
id: z.string(),
name: z.string(),
children: z.array(categorySchema).optional(),
})
);
```
## Form Integration Patterns
### React Hook Form
```typescript
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const formSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
age: z.number().int().positive().optional(),
});
type FormData = z.infer<typeof formSchema>;
function MyForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(formSchema),
});
const onSubmit = (data: FormData) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Form fields */}
</form>
);
}
```
## API Validation Patterns
### Server Action with Validation
```typescript
'use server';
import { z } from 'zod';
const createEntitySchema = z.object({
name: z.string().min(1).max(100),
type: z.enum(['character', 'location', 'item']),
description: z.string().optional(),
});
export async function createEntity(formData: FormData) {
const rawData = {
name: formData.get('name'),
type: formData.get('type'),
description: formData.get('description'),
};
// Validate with safeParse
const result = createEntitySchema.safeParse(rawData);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
};
}
// Type-safe data
const { name, type, description } = result.data;
// Process validated data
// ...
return { success: true, data: { id: '123' } };
}
```
### API Route with Validation
```typescript
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
const requestSchema = z.object({
query: z.string(),
filters: z.object({
type: z.array(z.string()).optional(),
tags: z.array(z.string()).optional(),
}).optional(),
});
export async function POST(request: NextRequest) {
const body = await request.json();
const result = requestSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid request', details: result.error.format() },
{ status: 400 }
);
}
const { query, filters } = result.data;
// Process validated data
// ...
return NextResponse.json({ results: [] });
}
```
## Worldbuilding-Specific Patterns
### Entity Validation
```typescript
const entitySchema = z.object({
id: z.string().uuid(),
type: z.enum(['character', 'location', 'item', 'faction', 'event']),
name: z.string().min(1).max(200),
description: z.string().max(5000).optional(),
tags: z.array(z.string()).max(20),
attributes: z.record(z.string(), z.any()),
metadata: z.object({
createdAt: z.date(),
updatedAt: z.date(),
createdBy: z.string(),
}),
});
```
### Relationship Validation
```typescript
const relationshipSchema = z.object({
id: z.string().uuid(),
sourceId: z.string().uuid(),
targetId: z.string().uuid(),
type: z.string().min(1).max(50),
strength: z.number().min(0).max(1).optional(),
metadata: z.record(z.string(), z.any()).optional(),
}).refine(
(data) => data.sourceId !== data.targetId,
{ message: 'Source and target must be different entities', path: ['targetId'] }
);
```
### Timeline Event Validation
```typescript
const timelineEventSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(200),
date: z.object({
year: z.number().int(),
month: z.number().int().min(1).max(12).optional(),
day: z.number().int().min(1).max(31).optional(),
}),
description: z.string().max(5000).optional(),
entityIds: z.array(z.string().uuid()),
}).refine(
(data) => {
if (data.date.day && !data.date.month) {
return false;
}
return true;
},
{ message: 'Month is required when day is specified', path: ['date', 'month'] }
);
```
## Best Practices
1. **Use `.safeParse()` over `.parse()`**: Prevents throwing exceptions
2. **Provide clear error messages**: Help users understand what went wrong
3. **Validate early**: At API boundaries and form submissions
4. **Use type inference**: `z.infer<typeof schema>` for TypeScript types
5. **Keep schemas colocated**: Near the code that uses them
6. **Document complex validations**: Add comments explaining business rules
7. **Test edge cases**: Empty values, boundary conditions, invalid formats
8. **Use refinements for complex logic**: When built-in validators aren't enough
9. **Consider performance**: Complex validations on large datasets may be slow
10. **Version your schemas**: When APIs evolve, maintain backward compatibility

View File

@@ -0,0 +1,324 @@
#!/usr/bin/env python3
"""
Generate Zod schemas from TypeScript interfaces/types.
Usage:
python generate_zod_schema.py --input types/entities.ts --output schemas/entities.ts
python generate_zod_schema.py --input-string "interface User { name: string; age: number; }"
"""
import re
import argparse
from pathlib import Path
from typing import Dict, List, Optional, Tuple
class TypeScriptToZodConverter:
"""Convert TypeScript types to Zod schemas."""
# Type mappings from TS to Zod
TYPE_MAP = {
'string': 'z.string()',
'number': 'z.number()',
'boolean': 'z.boolean()',
'Date': 'z.date()',
'any': 'z.any()',
'unknown': 'z.unknown()',
'null': 'z.null()',
'undefined': 'z.undefined()',
'void': 'z.void()',
}
def __init__(self):
self.interfaces: Dict[str, str] = {}
self.types: Dict[str, str] = {}
self.enums: Dict[str, List[str]] = {}
def convert_type(self, ts_type: str, optional: bool = False) -> str:
"""Convert a TypeScript type to Zod schema."""
ts_type = ts_type.strip()
# Handle optional (?)
if ts_type.endswith('?'):
ts_type = ts_type[:-1].strip()
optional = True
# Handle union with undefined/null
if ' | undefined' in ts_type or ' | null' in ts_type:
optional = True
ts_type = re.sub(r'\s*\|\s*(undefined|null)', '', ts_type).strip()
# Simple types
if ts_type in self.TYPE_MAP:
zod = self.TYPE_MAP[ts_type]
# Array types
elif ts_type.endswith('[]'):
inner_type = ts_type[:-2].strip()
inner_zod = self.convert_type(inner_type)
zod = f'z.array({inner_zod})'
# Generic Array<T>
elif ts_type.startswith('Array<') and ts_type.endswith('>'):
inner_type = ts_type[6:-1].strip()
inner_zod = self.convert_type(inner_type)
zod = f'z.array({inner_zod})'
# Record<K, V>
elif ts_type.startswith('Record<'):
match = re.match(r'Record<\s*(.+?)\s*,\s*(.+?)\s*>', ts_type)
if match:
value_type = match.group(2)
value_zod = self.convert_type(value_type)
zod = f'z.record({value_zod})'
else:
zod = 'z.record(z.any())'
# Union types
elif ' | ' in ts_type:
union_types = [t.strip() for t in ts_type.split('|')]
union_zods = [self.convert_type(t) for t in union_types]
zod = f'z.union([{", ".join(union_zods)}])'
# Literal types
elif ts_type.startswith("'") or ts_type.startswith('"'):
zod = f'z.literal({ts_type})'
# Reference to another interface/type/enum
elif ts_type in self.interfaces or ts_type in self.types or ts_type in self.enums:
zod = f'{ts_type}Schema'
else:
# Unknown type - use z.any() with comment
zod = f'z.any() /* TODO: define schema for {ts_type} */'
# Add optional modifier
if optional:
zod = f'{zod}.optional()'
return zod
def parse_interface(self, interface_str: str) -> Tuple[str, List[Tuple[str, str, bool, Optional[str]]]]:
"""Parse a TypeScript interface and return name and fields."""
# Extract interface name
name_match = re.search(r'interface\s+(\w+)', interface_str)
if not name_match:
raise ValueError("Could not parse interface name")
name = name_match.group(1)
# Extract fields
fields = []
field_pattern = re.compile(
r'^\s*(?:/\*\*\s*(.+?)\s*\*/\s*)?' # Optional JSDoc comment
r'(\w+)(\??)\s*:\s*(.+?);?\s*$', # fieldName?: type;
re.MULTILINE
)
body_match = re.search(r'\{(.+?)\}', interface_str, re.DOTALL)
if body_match:
body = body_match.group(1)
for match in field_pattern.finditer(body):
description = match.group(1)
field_name = match.group(2)
optional = match.group(3) == '?'
field_type = match.group(4).strip()
fields.append((field_name, field_type, optional, description))
return name, fields
def parse_type_alias(self, type_str: str) -> Tuple[str, str]:
"""Parse a TypeScript type alias."""
match = re.search(r'type\s+(\w+)\s*=\s*(.+?);?\s*$', type_str, re.DOTALL)
if not match:
raise ValueError("Could not parse type alias")
name = match.group(1)
type_def = match.group(2).strip().rstrip(';')
return name, type_def
def parse_enum(self, enum_str: str) -> Tuple[str, List[str]]:
"""Parse a TypeScript enum."""
name_match = re.search(r'enum\s+(\w+)', enum_str)
if not name_match:
raise ValueError("Could not parse enum name")
name = name_match.group(1)
# Extract enum values
values = []
body_match = re.search(r'\{(.+?)\}', enum_str, re.DOTALL)
if body_match:
body = body_match.group(1)
value_pattern = re.compile(r'(\w+)\s*(?:=\s*["\'](.+?)["\']\s*)?', re.MULTILINE)
for match in value_pattern.finditer(body):
value = match.group(2) if match.group(2) else match.group(1)
values.append(value)
return name, values
def interface_to_zod(self, name: str, fields: List[Tuple[str, str, bool, Optional[str]]]) -> str:
"""Convert interface fields to Zod schema."""
schema_lines = [f'export const {name}Schema = z.object({{']
for field_name, field_type, optional, description in fields:
zod_type = self.convert_type(field_type, optional)
# Add description if present
if description:
desc_line = f' {field_name}: {zod_type}.describe("{description}"),'
else:
desc_line = f' {field_name}: {zod_type},'
schema_lines.append(desc_line)
schema_lines.append('});')
schema_lines.append('')
schema_lines.append(f'export type {name} = z.infer<typeof {name}Schema>;')
return '\n'.join(schema_lines)
def type_alias_to_zod(self, name: str, type_def: str) -> str:
"""Convert type alias to Zod schema."""
zod_type = self.convert_type(type_def)
lines = [
f'export const {name}Schema = {zod_type};',
'',
f'export type {name} = z.infer<typeof {name}Schema>;'
]
return '\n'.join(lines)
def enum_to_zod(self, name: str, values: List[str]) -> str:
"""Convert enum to Zod schema."""
quoted_values = [f'"{v}"' for v in values]
if len(values) == 1:
zod_def = f'z.literal({quoted_values[0]})'
else:
zod_def = f'z.enum([{", ".join(quoted_values)}])'
lines = [
f'export const {name}Schema = {zod_def};',
'',
f'export type {name} = z.infer<typeof {name}Schema>;'
]
return '\n'.join(lines)
def convert_file(self, input_content: str) -> str:
"""Convert an entire TypeScript file to Zod schemas."""
output_lines = [
"import { z } from 'zod';",
'',
'// Auto-generated Zod schemas',
''
]
# Parse all interfaces
interface_pattern = re.compile(
r'(?:export\s+)?interface\s+\w+\s*\{[^}]*\}',
re.DOTALL
)
for match in interface_pattern.finditer(input_content):
interface_str = match.group(0)
try:
name, fields = self.parse_interface(interface_str)
self.interfaces[name] = interface_str
except ValueError:
pass
# Parse all type aliases
type_pattern = re.compile(
r'(?:export\s+)?type\s+\w+\s*=\s*[^;]+;?',
re.DOTALL
)
for match in type_pattern.finditer(input_content):
type_str = match.group(0)
try:
name, type_def = self.parse_type_alias(type_str)
self.types[name] = type_def
except ValueError:
pass
# Parse all enums
enum_pattern = re.compile(
r'(?:export\s+)?enum\s+\w+\s*\{[^}]*\}',
re.DOTALL
)
for match in enum_pattern.finditer(input_content):
enum_str = match.group(0)
try:
name, values = self.parse_enum(enum_str)
self.enums[name] = values
except ValueError:
pass
# Generate Zod schemas
# Enums first (no dependencies)
for name, values in self.enums.items():
output_lines.append(self.enum_to_zod(name, values))
output_lines.append('')
# Then type aliases
for name, type_def in self.types.items():
output_lines.append(self.type_alias_to_zod(name, type_def))
output_lines.append('')
# Finally interfaces
for name, interface_str in self.interfaces.items():
_, fields = self.parse_interface(interface_str)
output_lines.append(self.interface_to_zod(name, fields))
output_lines.append('')
return '\n'.join(output_lines)
def main():
parser = argparse.ArgumentParser(
description='Generate Zod schemas from TypeScript interfaces/types'
)
parser.add_argument(
'--input',
help='Input TypeScript file path'
)
parser.add_argument(
'--output',
help='Output file path for Zod schemas'
)
parser.add_argument(
'--input-string',
help='Input TypeScript code as string'
)
args = parser.parse_args()
# Read input
if args.input:
input_path = Path(args.input)
if not input_path.exists():
print(f"Error: Input file not found: {args.input}")
return 1
input_content = input_path.read_text(encoding='utf-8')
elif args.input_string:
input_content = args.input_string
else:
print("Error: Either --input or --input-string must be provided")
return 1
# Convert
converter = TypeScriptToZodConverter()
try:
output_content = converter.convert_file(input_content)
except Exception as e:
print(f"Error during conversion: {e}")
return 1
# Write output
if args.output:
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(output_content, encoding='utf-8')
print(f"Generated Zod schemas written to: {args.output}")
else:
print(output_content)
return 0
if __name__ == '__main__':
exit(main())