Skip to main content

Overview

Contract Kit provides automatic validation at runtime using your schema library of choice. All validation happens transparently, ensuring type safety without manual checks.

How Validation Works

  1. Contract Definition: You define schemas in your contracts
  2. Request Validation: Server automatically validates incoming requests
  3. Response Validation: Optionally validate responses in development
  4. Type Inference: TypeScript infers types from validated data

Request Validation

Automatic Validation

When a request arrives, Contract Kit automatically validates:
  • Path parameters: Extracted from the URL path
  • Query parameters: Parsed from the query string
  • Request body: Parsed from JSON
export const createTodo = todos
  .post("/api/todos")
  .body(z.object({
    title: z.string().min(1).max(100),
    completed: z.boolean().optional(),
  }))
  .response(201, TodoSchema);

// In your handler
export const POST = server.route(createTodo).handle(async ({ ctx, body }) => {
  // body is already validated and typed
  // You can safely use body.title without checks
  const todo = await ctx.ports.db.todos.create(body);
  return { status: 201, body: todo };
});

Validation Errors

If validation fails, Contract Kit automatically returns a 400 error:
{
  "message": "Invalid request body",
  "error": "title: String must contain at least 1 character(s)"
}

Schema Validation

Basic Types

import { z } from "zod";

// Primitives
z.string()
z.number()
z.boolean()
z.null()
z.undefined()

// Objects
z.object({
  name: z.string(),
  age: z.number(),
})

// Arrays
z.array(z.string())

Validation Rules

// String validation
z.string()
  .min(1, "Required")
  .max(100, "Too long")
  .email("Invalid email")
  .url("Invalid URL")
  .regex(/^[a-z]+$/, "Lowercase only")

// Number validation
z.number()
  .int("Must be integer")
  .positive("Must be positive")
  .min(1)
  .max(100)

// Custom validation
z.string().refine(
  (val) => val !== "admin",
  "Username 'admin' is reserved"
)

Optional and Nullable

z.string().optional() // string | undefined
z.string().nullable() // string | null
z.string().nullish() // string | null | undefined

// With defaults
z.string().optional().default("default value")
z.number().optional().default(0)

Transformations

// Transform after validation
z.string().transform((val) => val.toLowerCase())

// Coerce types
z.coerce.number() // "123" -> 123
z.coerce.boolean() // "true" -> true
z.coerce.date() // "2024-01-01" -> Date

Response Validation (Client)

Contract Kit’s client validates successful responses against your contract schemas:
import { createClient } from "contract-kit";
import { getTodo } from "@/contracts/todo";

const client = createClient({ baseUrl: "" });

// Throws if the response doesn't match the schema
const todo = await client.endpoint(getTodo).call({ path: { id: "123" } });

Error Schemas

Defining Error Responses

export const getTodo = todos
  .get("/api/todos/:id")
  .path(z.object({ id: z.string() }))
  .response(200, TodoSchema)
  .errors({
    404: z.object({
      message: z.string(),
    }),
    500: z.object({
      message: z.string(),
      code: z.string(),
    }),
  });

Using Error Schemas

export const GET = server.route(getTodo).handle(async ({ path }) => {
  const todo = await db.todos.findById(path.id);

  if (!todo) {
    return {
      status: 404,
      body: { message: "Todo not found" },
    };
  }

  return { status: 200, body: todo };
});

Schema Libraries

Contract Kit supports any Standard Schema library:
import { z } from "zod";

const schema = z.object({
  email: z.string().email(),
  age: z.number().int().positive(),
});

Validation Best Practices

Define all validation rules in your contracts, not in your handlers.
Provide clear, actionable error messages for validation failures.
Reuse common schemas across multiple contracts.
Test boundary conditions and invalid inputs in your tests.
Use schema descriptions to document requirements for API consumers.

Common Patterns

Reusable Schemas

const EmailSchema = z.string().email();
const PasswordSchema = z.string().min(8);

export const signUp = auth
  .post("/api/auth/signup")
  .body(z.object({
    email: EmailSchema,
    password: PasswordSchema,
  }));

Conditional Validation

const schema = z.object({
  type: z.enum(["user", "admin"]),
  permissions: z.array(z.string()).optional(),
}).refine(
  (data) => data.type !== "admin" || data.permissions,
  "Admin must have permissions"
);

Union Types

const PaymentSchema = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("card"),
    cardNumber: z.string(),
  }),
  z.object({
    type: z.literal("bank"),
    accountNumber: z.string(),
  }),
]);

Next Steps