Savvi Studio

Schema Generation Examples

Purpose: Code examples for PostgreSQL to Zod schema generation
Last Updated: 2024-11-28

This document contains practical examples of generated Zod schemas. For concepts and architecture, see Schema Builders.

Enum Examples

Simple Enum

// PostgreSQL
CREATE TYPE status AS ENUM ('active', 'inactive', 'pending');

// Generated
export const statusSchema = z.enum(['active', 'inactive', 'pending']);
export type Status = z.infer<typeof statusSchema>;

// Usage
const status: Status = 'active';  // ✅ Valid
const invalid: Status = 'unknown';  // ❌ Compile error

Enum with Special Characters

// PostgreSQL
CREATE TYPE priority AS ENUM ('high', 'medium', 'low', 'very-high');

// Generated
export const prioritySchema = z.enum(['high', 'medium', 'low', 'very-high']);
export type Priority = z.infer<typeof prioritySchema>;

Using Enum Schema

import { statusSchema } from '@db/types';

// Validate at runtime
const userStatus = statusSchema.parse('active');  // ✅ 'active'
const invalid = statusSchema.parse('unknown');  // ❌ Throws ZodError

// Safe parsing
const result = statusSchema.safeParse(userInput);
if (result.success) {
  console.log('Valid status:', result.data);
} else {
  console.error('Invalid status:', result.error);
}

Domain Examples

Email Domain

// PostgreSQL
CREATE DOMAIN email AS text
CHECK (VALUE ~ '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$');

// Generated
export const emailSchema = z.string().refine(
  (val) => /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(val),
  { message: 'Invalid email format' }
);
export type Email = z.infer<typeof emailSchema>;

// Usage
const email = emailSchema.parse('user@example.com');  // ✅
const invalid = emailSchema.parse('not-an-email');  // ❌ ZodError

Positive Integer Domain

// PostgreSQL
CREATE DOMAIN positive_int AS integer CHECK (VALUE > 0);

// Generated
export const positiveIntSchema = z.number().int().refine(
  (val) => val > 0,
  { message: 'Must be positive' }
);
export type PositiveInt = z.infer<typeof positiveIntSchema>;

// Usage
const age = positiveIntSchema.parse(25);  // ✅
const invalid = positiveIntSchema.parse(-5);  // ❌ ZodError: Must be positive

URL Domain

// PostgreSQL
CREATE DOMAIN url AS text
CHECK (VALUE ~ '^https?://');

// Generated
export const urlSchema = z.string().refine(
  (val) => /^https?:\/\//.test(val),
  { message: 'Must be a valid URL' }
);

// Usage
const website = urlSchema.parse('https://example.com');  // ✅
const invalid = urlSchema.parse('not-a-url');  // ❌ ZodError

Composite Type Examples

Simple Composite

// PostgreSQL
CREATE TYPE user_record AS (
    id bigint,
    email text,
    age integer
);

// Generated
export const userRecordSchema = z.object({
  id: z.bigint(),
  email: z.string(),
  age: z.number().int()
});
export type UserRecord = z.infer<typeof userRecordSchema>;

// Usage
const user: UserRecord = {
  id: 1n,
  email: 'user@example.com',
  age: 25
};

const validated = userRecordSchema.parse(user);  // ✅

Composite with Nullable Fields

// PostgreSQL
CREATE TYPE profile AS (
    user_id bigint,
    bio text,
    avatar_url text
);

// Generated
export const profileSchema = z.object({
  user_id: z.bigint(),
  bio: z.string().nullable(),
  avatar_url: z.string().nullable()
});
export type Profile = z.infer<typeof profileSchema>;

// Usage
const profile: Profile = {
  user_id: 1n,
  bio: null,  // ✅ Nullable
  avatar_url: 'https://example.com/avatar.jpg'
};

Nested Composite

// PostgreSQL
CREATE TYPE address AS (street text, city text, state text, zip text);
CREATE TYPE user_with_address AS (
    id bigint,
    name text,
    address address
);

// Generated (order matters - dependencies first)
export const addressSchema = z.object({
  street: z.string(),
  city: z.string(),
  state: z.string(),
  zip: z.string()
});
export type Address = z.infer<typeof addressSchema>;

export const userWithAddressSchema = z.object({
  id: z.bigint(),
  name: z.string(),
  address: addressSchema  // References address schema
});
export type UserWithAddress = z.infer<typeof userWithAddressSchema>;

// Usage
const user: UserWithAddress = {
  id: 1n,
  name: 'John Doe',
  address: {
    street: '123 Main St',
    city: 'Springfield',
    state: 'IL',
    zip: '62701'
  }
};

Array Examples

Simple Arrays

// PostgreSQL: text[]
// Generated
z.array(z.string())

// Usage
const tags = ['typescript', 'postgresql', 'zod'];
const validated = z.array(z.string()).parse(tags);  // ✅

// PostgreSQL: integer[]
// Generated
z.array(z.number().int())

// Usage
const scores = [85, 90, 92, 88];
const validated = z.array(z.number().int()).parse(scores);  // ✅

// PostgreSQL: bigint[]
// Generated
z.array(z.bigint())

// Usage
const ids = [1n, 2n, 3n, 4n];
const validated = z.array(z.bigint()).parse(ids);  // ✅

Nested Arrays

// PostgreSQL: integer[][]
// Generated
z.array(z.array(z.number().int()))

// Usage
const matrix = [
  [1, 2, 3],
  [4, 5, 6],
  [7, 8, 9]
];
const validated = z.array(z.array(z.number().int())).parse(matrix);  // ✅

// PostgreSQL: text[][][]
// Generated
z.array(z.array(z.array(z.string())))

// Usage
const cube = [
  [['a', 'b'], ['c', 'd']],
  [['e', 'f'], ['g', 'h']]
];

Arrays of Complex Types

// PostgreSQL: user_record[]
// Generated
z.array(userRecordSchema)

// Usage
const users: UserRecord[] = [
  { id: 1n, email: 'user1@example.com', age: 25 },
  { id: 2n, email: 'user2@example.com', age: 30 }
];
const validated = z.array(userRecordSchema).parse(users);  // ✅

// PostgreSQL: status[]
// Generated
z.array(statusSchema)

// Usage
const statuses = ['active', 'inactive', 'pending'];
const validated = z.array(statusSchema).parse(statuses);  // ✅

Range Type Examples

Date Range

// PostgreSQL: daterange
// Generated
export const daterangeSchema = z.object({
  lower: z.string().date().nullable(),
  upper: z.string().date().nullable(),
  lowerInclusive: z.boolean(),
  upperInclusive: z.boolean()
});
export type Daterange = z.infer<typeof daterangeSchema>;

// Usage
const eventDateRange: Daterange = {
  lower: '2024-01-01',
  upper: '2024-12-31',
  lowerInclusive: true,
  upperInclusive: true
};

Integer Range

// PostgreSQL: int4range
// Generated
export const int4rangeSchema = z.object({
  lower: z.number().int().nullable(),
  upper: z.number().int().nullable(),
  lowerInclusive: z.boolean(),
  upperInclusive: z.boolean()
});
export type Int4range = z.infer<typeof int4rangeSchema>;

// Usage
const scoreRange: Int4range = {
  lower: 0,
  upper: 100,
  lowerInclusive: true,
  upperInclusive: true
};

Timestamp Range

// PostgreSQL: tstzrange
// Generated
export const tstzrangeSchema = z.object({
  lower: z.string().datetime().nullable(),
  upper: z.string().datetime().nullable(),
  lowerInclusive: z.boolean(),
  upperInclusive: z.boolean()
});

// Usage
const businessHours = {
  lower: '2024-01-15T09:00:00Z',
  upper: '2024-01-15T17:00:00Z',
  lowerInclusive: true,
  upperInclusive: false
};

Advanced Patterns

Circular References

// PostgreSQL
CREATE TYPE tree_node AS (
    id bigint,
    value text,
    children tree_node[]
);

// Generated (with lazy evaluation)
export const treeNodeSchema: z.ZodType<TreeNode> = z.lazy(() =>
  z.object({
    id: z.bigint(),
    value: z.string(),
    children: z.array(treeNodeSchema)  // Self-reference
  })
);
export type TreeNode = z.infer<typeof treeNodeSchema>;

// Usage
const tree: TreeNode = {
  id: 1n,
  value: 'root',
  children: [
    {
      id: 2n,
      value: 'child1',
      children: []
    },
    {
      id: 3n,
      value: 'child2',
      children: [
        {
          id: 4n,
          value: 'grandchild',
          children: []
        }
      ]
    }
  ]
};

Schema Composition

// Reuse schemas
const emailSchema = z.string().email();
const phoneSchema = z.string().regex(/^\d{3}-\d{3}-\d{4}$/);

// Compose into larger schema
export const contactInfoSchema = z.object({
  email: emailSchema,
  phone: phoneSchema.optional(),
  preferredContact: z.enum(['email', 'phone'])
});

// Use in multiple places
export const userSchema = z.object({
  id: z.bigint(),
  name: z.string(),
  contact: contactInfoSchema
});

export const vendorSchema = z.object({
  id: z.bigint(),
  companyName: z.string(),
  contact: contactInfoSchema  // Reused
});

Union Types

// Multiple possible shapes
export const notificationSchema = z.union([
  z.object({
    type: z.literal('email'),
    to: z.string().email(),
    subject: z.string(),
    body: z.string()
  }),
  z.object({
    type: z.literal('sms'),
    to: z.string(),
    message: z.string()
  }),
  z.object({
    type: z.literal('push'),
    deviceToken: z.string(),
    title: z.string(),
    body: z.string()
  })
]);

// Usage with type guards
function sendNotification(notification: z.infer<typeof notificationSchema>) {
  const validated = notificationSchema.parse(notification);
  
  if (validated.type === 'email') {
    // TypeScript knows this has 'to', 'subject', 'body'
    sendEmail(validated.to, validated.subject, validated.body);
  } else if (validated.type === 'sms') {
    // TypeScript knows this has 'to', 'message'
    sendSMS(validated.to, validated.message);
  } else {
    // TypeScript knows this has 'deviceToken', 'title', 'body'
    sendPush(validated.deviceToken, validated.title, validated.body);
  }
}

Testing Examples

Unit Tests for Schemas

import { describe, it, expect } from 'vitest';
import { statusSchema, userRecordSchema, emailSchema } from '@db/types';
import { ZodError } from 'zod';

describe('statusSchema', () => {
  it('accepts valid status values', () => {
    expect(() => statusSchema.parse('active')).not.toThrow();
    expect(() => statusSchema.parse('inactive')).not.toThrow();
    expect(() => statusSchema.parse('pending')).not.toThrow();
  });
  
  it('rejects invalid status values', () => {
    expect(() => statusSchema.parse('unknown')).toThrow(ZodError);
    expect(() => statusSchema.parse('')).toThrow(ZodError);
    expect(() => statusSchema.parse(null)).toThrow(ZodError);
  });
});

describe('userRecordSchema', () => {
  it('validates complete user record', () => {
    const user = {
      id: 1n,
      email: 'user@example.com',
      age: 25
    };
    
    const result = userRecordSchema.parse(user);
    expect(result).toEqual(user);
  });
  
  it('rejects incomplete user record', () => {
    const incomplete = {
      id: 1n,
      email: 'user@example.com'
      // Missing age
    };
    
    expect(() => userRecordSchema.parse(incomplete)).toThrow(ZodError);
  });
  
  it('rejects wrong types', () => {
    const wrongTypes = {
      id: '1',  // Should be bigint
      email: 'user@example.com',
      age: 25
    };
    
    expect(() => userRecordSchema.parse(wrongTypes)).toThrow(ZodError);
  });
});

describe('emailSchema', () => {
  it('accepts valid email addresses', () => {
    expect(() => emailSchema.parse('user@example.com')).not.toThrow();
    expect(() => emailSchema.parse('first.last@company.co.uk')).not.toThrow();
  });
  
  it('rejects invalid email addresses', () => {
    expect(() => emailSchema.parse('not-an-email')).toThrow(ZodError);
    expect(() => emailSchema.parse('@example.com')).toThrow(ZodError);
    expect(() => emailSchema.parse('user@')).toThrow(ZodError);
  });
});

Integration Tests

import { withTestClient } from '@/test/utils';
import { createUser, getUser } from '@db/functions';
import { userRecordSchema } from '@db/types';

describe('schema validation in functions', () => {
  it('validates input and output', async () => {
    await withTestClient(async (client) => {
      // Function validates input with schema
      const userId = await createUser(client, {
        p_email: 'test@example.com',
        p_age: 25
      });
      
      expect(userId).toBeGreaterThan(0n);
      
      // Function validates output with schema
      const user = await getUser(client, { p_id: userId });
      
      // Returned data matches schema
      expect(() => userRecordSchema.parse(user)).not.toThrow();
      expect(user.email).toBe('test@example.com');
      expect(user.age).toBe(25);
    });
  });
  
  it('throws on invalid input', async () => {
    await withTestClient(async (client) => {
      // Invalid email format
      await expect(
        createUser(client, {
          p_email: 'not-an-email',
          p_age: 25
        })
      ).rejects.toThrow(ZodError);
    });
  });
});

These examples demonstrate how PostgreSQL types are converted to Zod schemas and used in practice.