Zod Schema Sharing Guide
SwallowKit's core feature is Zod Schema Sharing, which enables type-safe, validated data flow across your entire stack—from frontend to BFF layer to Azure Functions to database storage.
Note: This guide explains the concepts and benefits of Zod schema sharing. For practical CRUD code generation, see the Scaffold Guide.
Why Zod Schema Sharing?
The Problem
In traditional full-stack development, you often define types and validation logic multiple times:
- Frontend: Form validation with one library
- Backend API: Request validation with another library
- Database: Schema definitions in ORM or separate files
- TypeScript Types: Manually maintained interfaces
This leads to:
- ❌ Code duplication
- ❌ Inconsistent validation
- ❌ Type drift between layers
- ❌ Maintenance overhead
The SwallowKit Solution
Define your schema once with Zod and use it everywhere:
// lib/models/user.ts - Single Source of Truth
import { z } from 'zod';
export const user = z.object({
id: z.string(),
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
age: z.number().min(18, 'Must be 18 or older'),
createdAt: z.string().default(() => new Date().toISOString()),
});
export type User = z.infer<typeof user>;This single schema provides:
- ✅ TypeScript types (
User) - ✅ Runtime validation
- ✅ Database integration (via scaffold)
- ✅ Error messages
- ✅ Default values
💡 Practical usage: For information on how SwallowKit automatically generates CRUD operations from Zod schemas, please see the Scaffold Guide.
Usage Across Layers
Layer 1: Frontend with SwallowKit API Client
SwallowKit provides a simple HTTP client for calling backend APIs:
// app/users/page.tsx
'use client'
import { api } from '@/lib/api/backend';
import type { User } from '@/lib/models/user';
import { useState, useEffect } from 'react';
export default function UsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [error, setError] = useState('');
useEffect(() => {
// Fetch from BFF endpoint
api.get<User[]>('/api/users')
.then(setUsers)
.catch(err => setError(err.message));
}, []);
const handleCreate = async (formData: FormData) => {
try {
// Validated by backend
const newUser = await api.post<User>('/api/users', {
id: crypto.randomUUID(),
name: formData.get('name') as string,
email: formData.get('email') as string,
age: Number(formData.get('age')),
});
setUsers([...users, newUser]);
} catch (err: any) {
setError(err.message); // Backend validation error
}
};
return (
<div>
{error && <div className="error">{error}</div>}
{users.map(user => (
<div key={user.id}>{user.name} - {user.email}</div>
))}
</div>
);
}💡 Code generation: The scaffold command can automatically generate complete UI components with form validation. Please see the Scaffold Guide for details.
Layer 2: Next.js BFF API Routes (Auto-Generated)
SwallowKit's scaffold command generates BFF API routes that validate requests:
// Generated by: npx swallowkit scaffold user
// app/api/user/route.ts (Next.js BFF API)
import { NextRequest, NextResponse } from 'next/server';
import { user } from '@/lib/models/user';
const FUNCTIONS_BASE_URL = process.env.FUNCTIONS_BASE_URL || 'http://localhost:7071';
export async function POST(request: NextRequest) {
const body = await request.json();
// Validate with Zod schema before forwarding to Azure Functions
const result = user.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: result.error.errors[0].message },
{ status: 400 }
);
}
// Forward validated data to Azure Functions
const response = await fetch(`${FUNCTIONS_BASE_URL}/api/user`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(result.data),
});
const data = await response.json();
return NextResponse.json(data);
}📚 Reference: For complete examples of generated API routes, please see the Scaffold Guide.
Share the same schema in your independent backend:
// Generated by: npx swallowkit scaffold lib/models/user.ts
// functions/src/user.ts (Azure Functions)
import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';
import { user } from './models/user';
import { CosmosClient } from '@azure/cosmos';
const cosmosClient = new CosmosClient(process.env.CosmosDBConnection!);
const database = cosmosClient.database('MyDatabase');
const container = database.container('Users');
export async function createUser(request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> {
const body = await request.json();
// Validate request with shared Zod schema
const result = user.safeParse(body);
if (!result.success) {
return {
status: 400,
jsonBody: { error: result.error.errors[0].message }
};
}
// Save validated data to Cosmos DB
const { resource: created } = await container.items.create(result.data);
return {
status: 201,
jsonBody: created
};
}
app.http('createUser', {
methods: ['POST'],
authLevel: 'anonymous',
handler: createUser
});📚 Reference: For examples of generated Azure Functions with full CRUD operations, please see the Scaffold Guide.
Advanced Patterns
Partial Schemas
Validate only specific fields for updates:
// Only validate name and email for profile updates
const updateProfile = user.pick({
name: true,
email: true
});
### Nested Schemas
Compose complex data structures:
> **Recommended**: For parent-child relationships, prefer nesting schemas directly rather than using ID-based references. This preserves type safety and aligns with Cosmos DB's document model.
```typescript
const address = z.object({
street: z.string(),
city: z.string(),
postalCode: z.string(),
});
const userWithAddress = user.extend({
address: address,
});
export type UserWithAddress = z.infer<typeof userWithAddress>;Custom Validation
Add business logic validation:
const product = z.object({
id: z.string(),
name: z.string(),
price: z.number().positive(),
discount: z.number().min(0).max(100),
}).refine(
(data) => {
// Custom validation: discounted price must be positive
const finalPrice = data.price * (1 - data.discount / 100);
return finalPrice > 0;
},
{ message: 'Discounted price must be greater than 0' }
);Transformations
Transform data during validation:
const userInputSchema = z.object({
name: z.string().trim().toLowerCase(), // Normalize name
email: z.string().email().toLowerCase(), // Normalize email
age: z.string().transform(Number), // Convert string to number
});Best Practices
1. Model File Structure
Follow SwallowKit's recommended structure for model files:
// lib/models/user.ts
import { z } from 'zod';
// 1. Define the Zod schema (use camelCase)
export const user = z.object({
id: z.string(),
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
age: z.number().min(18, 'Must be 18 or older'),
createdAt: z.string().default(() => new Date().toISOString()),
});
// 2. Export the TypeScript type (use PascalCase)
export type User = z.infer<typeof user>;💡 SwallowKit Convention:
- Schema name:
camelCase(e.g.,user,product) - Type name:
PascalCase(e.g.,User,Product)
2. Use safeParse() for Error Handling
// ✅ Good: Handle errors gracefully
const result = user.safeParse(data);
if (!result.success) {
console.error(result.error.errors);
return { error: 'Validation failed' };
}
// ❌ Bad: Throws exception
const parsed = user.parse(data); // Can throw!3. Validation Messages for Better UX
Provide clear, user-friendly error messages:
const product = z.object({
name: z.string().min(1, 'Product name is required'),
price: z.number().positive('Price must be greater than 0'),
category: z.enum(['electronics', 'clothing', 'books'], {
errorMap: () => ({ message: 'Please select a valid category' })
}),
});4. Default Values and Optional Fields
const todo = z.object({
id: z.string(),
title: z.string().min(1, 'Title is required'),
completed: z.boolean().default(false), // Default value
description: z.string().optional(), // Optional field
createdAt: z.string().default(() => new Date().toISOString()),
});SwallowKit's scaffold command automatically generates appropriate UI:
- Optional fields are not marked as required in forms
- Default values are pre-populated
📚 Reference: For more details on type-appropriate UI generation, please see the Scaffold Guide.
5. Foreign Key Naming Convention
For automatic foreign key detection in SwallowKit:
const todo = z.object({
id: z.string(),
categoryId: z.string().min(1, 'Category is required'), // Detected as FK to Category
userId: z.string().min(1, 'User is required'), // Detected as FK to User
});Pattern: <ModelName>Id → References <ModelName> model
Recommended: For parent-child relationships, prefer nested schema references over ID-based foreign keys. See the Scaffold Guide for details.
📚 Reference: For more details on foreign key relationships, please see the Scaffold Guide.
Summary
Zod Schema Sharing in SwallowKit provides:
✅ Single Source of Truth - Define once, use everywhere
✅ Type Safety - Compile-time and runtime validation
✅ Consistency - Same validation logic across all layers
✅ Developer Experience - IntelliSense, auto-completion, error messages
✅ Maintainability - Change schema once, updates everywhere
This approach eliminates type drift, reduces bugs, and improves developer productivity across your entire stack.
Next Steps
- Scaffold Guide - Generate complete CRUD operations from your Zod schemas
- Zod Documentation - Learn advanced Zod features and patterns
- README - Get started with SwallowKit
