Skip to content

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: "2025-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: "2025-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