Contracts

A contract is the single source of truth for an API endpoint. It describes the HTTP method, path, parameters, response shape, and error cases — all in TypeScript.

Contract groups

Use createContractGroup to create a group of related contracts. Groups can share configuration like metadata and shared response schemas.

import { createContract, createContractGroup } from "contract-kit";
import { z } from "zod";

const TodoSchema = z.object({
  id: z.string(),
  title: z.string(),
  completed: z.boolean(),
});

const CreateTodoSchema = z.object({
  title: z.string().min(1),
  completed: z.boolean().optional(),
});

const todos = createContractGroup()
  .namespace("todos")
  .meta({ auth: "required" })
  .responses({
    401: z.object({
      code: z.literal("UNAUTHORIZED"),
      message: z.string(),
    }),
  });

The namespace is used for OpenAPI tags. Metadata and shared response schemas are inherited by all contracts in the group.

Defining contracts

Chain methods to describe the endpoint shape.

GET with path parameters

export const getTodo = todos
  .get("/api/todos/:id")
  .pathParams(z.object({ id: z.string() }))
  .responses({ 200: TodoSchema });

GET with query parameters

export const listTodos = todos
  .get("/api/todos")
  .query(z.object({
    completed: z.boolean().optional(),
    limit: z.number().int().min(1).max(100).optional(),
    offset: z.number().int().min(0).optional(),
  }))
  .responses({ 200: z.object({
    todos: z.array(TodoSchema),
    total: z.number(),
    offset: z.number(),
  }) });

POST with a request body

export const createTodo = todos
  .post("/api/todos")
  .body(CreateTodoSchema)
  .responses({ 201: TodoSchema });

PATCH with path and body

export const updateTodo = todos
  .patch("/api/todos/:id")
  .pathParams(z.object({ id: z.string() }))
  .body(z.object({
    title: z.string().optional(),
    completed: z.boolean().optional(),
  }))
  .responses({ 200: TodoSchema });

DELETE

export const deleteTodo = todos
  .delete("/api/todos/:id")
  .pathParams(z.object({ id: z.string() }))
  .responses({ 204: null });

Use null for void responses like 204 No Content.

Auto-generated names

If you do not pass a custom name, Contract Kit generates one from the HTTP method and full path.

createContract({ method: "GET", path: "/users/:id" }).name;
// "getUsersById"

createContract({ method: "POST", path: "/api/todos" }).name;
// "createTodos"

The generated name ignores a leading /api, includes path parameters as By..., and is reused by downstream integrations like React Query and OpenAPI. Pass name explicitly when you want a custom identifier.

Error responses

Define expected responses per status code with .responses(...).

export const createTodo = todos
  .post("/api/todos")
  .body(CreateTodoSchema)
  .responses({
    201: TodoSchema,
    400: z.object({
      code: z.literal("INVALID_TODO"),
      message: z.string(),
      details: z.object({
        errors: z.array(z.string()),
      }),
    }),
    401: z.object({
      code: z.literal("UNAUTHORIZED"),
      message: z.string(),
    }),
  });

Shared response schemas defined on a contract group are inherited by all contracts. Per-contract responses are merged with group responses.

Route-owned error responses can use any schema you declare. The examples use { code, message, details? } because it matches Contract Kit's framework-owned error envelope.

Error catalog entries can also carry an optional Standard Schema response body. This keeps the error code, status, message, and route-owned response schema in one place:

export const errors = defineErrors({
  TodoNotFound: {
    code: "TODO_NOT_FOUND",
    status: 404,
    message: "Todo not found",
    responseSchema: z.object({
      code: z.literal("TODO_NOT_FOUND"),
      message: z.string(),
      details: z.object({ id: z.string() }),
    }),
  },
});

export const getTodo = todos.get("/api/todos/:id").responses({
  200: TodoSchema,
  404: errors.TodoNotFound.responseSchema,
});

Metadata

Attach metadata to contracts for use in server hooks.

const DataSchema = z.object({
  id: z.string(),
  value: z.string(),
});

const PaymentSchema = z.object({
  amount: z.number().positive(),
  currency: z.string(),
});

const PaymentResultSchema = z.object({
  id: z.string(),
  status: z.enum(["pending", "succeeded", "failed"]),
});

// Authentication
export const getProtectedData = todos
  .get("/api/protected")
  .meta({ auth: "required" })
  .responses({ 200: DataSchema });

// Rate limiting
export const createTodo = todos
  .post("/api/todos")
  .meta({ rateLimit: { max: 10, windowSec: 60 } })
  .body(CreateTodoSchema)
  .responses({ 201: TodoSchema });

// Idempotency
const payments = createContractGroup().namespace("payments");

export const createPayment = payments
  .post("/api/payments")
  .meta({ idempotency: { enabled: true } })
  .body(PaymentSchema)
  .responses({ 201: PaymentResultSchema });

Metadata is available to hooks via contract.metadata — you can use it to implement auth checks, rate limiting, or anything else.

Schema libraries

Contract Kit works with any Standard Schema library for runtime validation in contracts, the server, and the client. OpenAPI generation currently requires Zod schemas.

Zod

import { z } from "zod";

const TodoSchema = z.object({
  id: z.string(),
  title: z.string(),
  completed: z.boolean(),
});

Valibot

import * as v from "valibot";

const TodoSchema = v.object({
  id: v.string(),
  title: v.string(),
  completed: v.boolean(),
});

ArkType

import { type } from "arktype";

const TodoSchema = type({
  id: "string",
  title: "string",
  completed: "boolean",
});

Introspection

Contracts expose their path and schemas for runtime inspection.

contract.path              // "/api/todos/:id"
contract.schema.pathParams // Standard Schema or null
contract.schema.query      // Standard Schema or null
contract.schema.body       // Standard Schema or null
contract.schema.responses  // { 200: StandardSchema, 404: StandardSchema, ... }