Skip to content

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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

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

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

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

typescript
// ✅ 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:

typescript
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

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

typescript
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

Released under the MIT License.