Schema Validation with Zod V4

Meister Bill uses Zod V4 for runtime type validation and schema definitions across all applications.

Overview

All schemas are centralized in the schemas/ package and shared between frontend, backend, and mobile apps.

Zod V4 Migration

The codebase has been fully migrated to Zod V4. When working with schemas:

Import from "zod/v4"

Always import from "zod/v4" instead of "zod":

// ✅ Correct
import { z } from "zod/v4";

// ❌ Wrong
import { z } from "zod";

Use z.ZodTypeAny for Generic Types

// ✅ Correct
interface Config {
  schema: z.ZodTypeAny;
}

// ❌ Wrong (Zod V3 syntax)
interface Config {
  schema: z.ZodType<any>;
}

Avoid Circular Dependencies

Import directly from source files, not from the index file:

// ✅ Good
import { CountryCodeSchema } from "./CountryCodeSchema";

// ❌ Bad - may cause circular dependencies
import { CountryCodeSchema } from ".";

Schema Package Structure

schemas/
├── src/
│   ├── index.ts                    # Main export (respects dependency order)
│   ├── *Schema.ts                  # Individual schema files
│   ├── *Schema.test.ts             # Schema tests
│   ├── AddressFormatters.ts        # Country-specific address formatting
│   ├── CountryNames.ts             # Country code to name mapping
│   ├── forms/                      # Form validation schemas
│   ├── newsletter/                 # Newsletter-related schemas
│   └── openapi/                    # OpenAPI request/response schemas
└── package.json                    # Published as @meisterbill/schemas

Usage Examples

Defining a Schema

// schemas/src/CustomerSchema.ts
import { z } from "zod/v4";

export const customerSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1, "Name is required"),
  email: z.string().email("Invalid email"),
  type: z.enum(["individual", "business"]),
  created_at: z.string().datetime(),
});

export type Customer = z.infer<typeof customerSchema>;

Using Schemas in API

// apps/api/src/routes/customers.ts
import { customerSchema } from "@meisterbill/schemas";

app.post("/customers", async (c) => {
  const body = await c.req.json();

  // Validate request body
  const result = customerSchema.safeParse(body);

  if (!result.success) {
    return c.json({ error: result.error }, 400);
  }

  const customer = result.data;
  // ... save to database
});

Using Schemas in Frontend

// apps/web/components/forms/Customer.vue
<script setup lang="ts">
import { customerSchema } from "@meisterbill/schemas";

const form = reactive({
  name: "",
  email: "",
  type: "individual",
});

const validate = () => {
  const result = customerSchema.safeParse(form);

  if (!result.success) {
    errors.value = result.error.flatten().fieldErrors;
    return false;
  }

  return true;
};
</script>

Form Validation Schemas

The schemas/src/forms/ directory contains form-specific validation:

// schemas/src/forms/SignUpSchema.ts
import { z } from "zod/v4";

export const signUpSchema = z.object({
  email: z.string().email("Invalid email address"),
  password: z.string()
    .min(8, "Password must be at least 8 characters")
    .regex(/[A-Z]/, "Password must contain uppercase letter")
    .regex(/[0-9]/, "Password must contain a number"),
  terms_accepted: z.literal(true, {
    errorMap: () => ({ message: "You must accept the terms" }),
  }),
});

export type SignUpForm = z.infer<typeof signUpSchema>;

Testing Schemas

All schemas have comprehensive test coverage:

// schemas/src/CustomerSchema.test.ts
import { describe, it, expect } from "vitest";
import { customerSchema } from "./CustomerSchema";

describe("customerSchema", () => {
  it("validates valid customer", () => {
    const result = customerSchema.safeParse({
      id: "123e4567-e89b-12d3-a456-426614174000",
      name: "John Doe",
      email: "john@example.com",
      type: "individual",
      created_at: "2026-01-01T00:00:00Z",
    });

    expect(result.success).toBe(true);
  });

  it("rejects invalid email", () => {
    const result = customerSchema.safeParse({
      id: "123e4567-e89b-12d3-a456-426614174000",
      name: "John Doe",
      email: "invalid-email",
      type: "individual",
      created_at: "2026-01-01T00:00:00Z",
    });

    expect(result.success).toBe(false);
    expect(result.error?.issues[0].message).toContain("Invalid email");
  });
});

Running Schema Tests

pnpm --filter @meisterbill/schemas test        # Run all schema tests
pnpm --filter @meisterbill/schemas build       # Build for distribution

Test Count: 414 passing tests

OpenAPI Integration

Schemas are used to generate OpenAPI documentation:

// schemas/src/openapi/CustomerSchemas.ts
import { z } from "zod/v4";
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";

extendZodWithOpenApi(z);

export const createCustomerRequestSchema = z.object({
  name: z.string().openapi({ example: "John Doe" }),
  email: z.string().email().openapi({ example: "john@example.com" }),
}).openapi("CreateCustomerRequest");

Best Practices

1. Single Source of Truth

Define schemas once in the schemas/ package, use everywhere:

// ✅ Good
import { invoiceSchema } from "@meisterbill/schemas";

// ❌ Bad - duplicating schema
const invoiceSchema = z.object({ ... });

2. Use Type Inference

Let TypeScript infer types from schemas:

// ✅ Good
export type Invoice = z.infer<typeof invoiceSchema>;

// ❌ Bad - manual type definition
export type Invoice = {
  id: string;
  // ... duplicate work
};

3. Coerce Types When Needed

Use .coerce for automatic type conversion:

const schema = z.object({
  age: z.coerce.number(), // Converts "25" → 25
  active: z.coerce.boolean(), // Converts "true" → true
});

4. Custom Error Messages

Provide clear, user-friendly error messages:

const schema = z.object({
  email: z.string().email("Please enter a valid email address"),
  password: z.string().min(8, "Password must be at least 8 characters long"),
});

5. Optional vs Nullable

Be explicit about optional and nullable fields:

const schema = z.object({
  name: z.string(),                    // Required
  nickname: z.string().optional(),     // Can be undefined
  middle_name: z.string().nullable(),  // Can be null
  suffix: z.string().nullish(),        // Can be null or undefined
});

Common Schemas

Key schemas available in the package:

  • invoiceSchema - Invoice/document validation
  • customerSchema - Customer data
  • addressSchema - Address with country-specific validation
  • paymentSchema - Payment records
  • projectSchema - Project configuration
  • workUnitSchema - Billable work units
  • serviceProviderSchema - User/account data

See Also