npm package: @ivancerovina/contracts
Description: Type-safe API contracts with Zod — define routes, infer types, consume with a typed HTTP client
Author: Ivan Cerovina
Latest version: 1.0.0

# @ivancerovina/contracts

Define type-safe API contracts with Zod schemas, infer types, and consume them with a fully typed HTTP client.

**Only dependency:** `zod`

## Install

```bash
pnpm add @ivancerovina/contracts zod
```

## Entry points

| Import | Contents |
|--------|----------|
| `@ivancerovina/contracts` | `createContract`, type helpers, pagination, response schemas |
| `@ivancerovina/contracts/client` | `createHttpClient`, `ContractError` |

## Defining a contract

A contract describes an API namespace — its routes, error codes, and WebSocket events.

```typescript
import { createContract, offsetPagination } from "@ivancerovina/contracts";
import { z } from "zod";

// Reusable schemas — plain Zod, defined above the contract
const taskSchema = z.object({
  id: z.string().uuid(),
  title: z.string().min(1).max(255),
  status: z.enum(["todo", "in_progress", "done"]),
  assigneeId: z.string().uuid().nullable(),
  createdAt: z.string().datetime(),
});

export const TaskContract = createContract({
  name: "Tasks",
  description: "Task management",
  baseRoute: "/tasks",

  // Custom error codes (default codes like INTERNAL_SERVER_ERROR are never declared here)
  errors: {
    TASK_NOT_FOUND: { status: 404 },
    PROJECT_NOT_FOUND: { status: 404 },
  },

  routes: {
    listTasks: {
      method: "GET",
      path: "/",
      name: "List Tasks",
      description: "Paginated task list",
      query: offsetPagination().extend({
        status: z.enum(["todo", "in_progress", "done"]).optional(),
      }),
      data: z.object({
        tasks: z.array(taskSchema),
        total: z.number(),
      }),
      errors: ["PROJECT_NOT_FOUND"],
    },

    getTask: {
      method: "GET",
      path: "/:taskId",
      name: "Get Task",
      description: "Get a single task",
      params: z.object({ taskId: z.string().uuid() }),
      data: taskSchema,
      errors: ["TASK_NOT_FOUND"],
    },

    createTask: {
      method: "POST",
      path: "/",
      name: "Create Task",
      description: "Create a new task",
      body: z.object({
        title: z.string().min(1).max(255),
        assigneeId: z.string().uuid().nullable().default(null),
      }),
      data: taskSchema,
      errors: ["PROJECT_NOT_FOUND"],
    },
  },
});
```

### Route definition fields

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `method` | `"GET" \| "POST" \| "PATCH" \| "PUT" \| "DELETE"` | yes | HTTP method |
| `path` | `string` | yes | Path relative to `baseRoute`, supports `:param` |
| `name` | `string` | yes | Human-readable name |
| `description` | `string` | yes | What the route does |
| `params` | `ZodObject` | no | Path parameter schema (required when path has `:param`) |
| `query` | `ZodObject` | no | Query string schema |
| `body` | `ZodObject` | no | Request body schema |
| `data` | `ZodSchema` | yes | Response data schema (the `D` in `{ success: true, data: D }`) |
| `errors` | `string[]` | no | References to keys in the contract's `errors` object |

### What `createContract` adds

Each route gets two computed schemas:

- **`raw`** — `z.discriminatedUnion("success", [successEnvelope, errorEnvelope])` — the full response shape
- **`error`** — the error envelope schema

These are used by the SDK's `@BindContract` decorator at runtime.

## Type inference

All helpers use `z.output` (post-transform types — correct for coerced query params and defaults).

```typescript
import type {
  InferQuery,
  InferBody,
  InferParams,
  InferData,
  InferErrors,
  InferField,
} from "@ivancerovina/contracts";

// Extract the parsed query type
type Q = InferQuery<typeof TaskContract, "listTasks">;
// → { status?: "todo" | "in_progress" | "done"; page: number; limit: number }

// Extract the parsed body type
type B = InferBody<typeof TaskContract, "createTask">;
// → { title: string; assigneeId: string | null }

// Extract path params
type P = InferParams<typeof TaskContract, "getTask">;
// → { taskId: string }

// Extract response data
type D = InferData<typeof TaskContract, "getTask">;
// → { id: string; title: string; status: "todo" | "in_progress" | "done"; ... }

// Extract possible error codes as a union
type E = InferErrors<typeof TaskContract, "getTask">;
// → "TASK_NOT_FOUND"

// Access any top-level contract field
type Name = InferField<typeof TaskContract, "name">;
// → "Tasks"
```

Returns `never` when a field doesn't exist on the route (e.g., `InferBody` on a GET route with no body).

### `ExtractRouteParams`

Template literal type that extracts `:param` segments from a path string:

```typescript
import type { ExtractRouteParams } from "@ivancerovina/contracts";

type A = ExtractRouteParams<"/">;
// → Record<string, never>

type B = ExtractRouteParams<"/:taskId">;
// → { taskId: string }

type C = ExtractRouteParams<"/:taskId/comments/:commentId">;
// → { taskId: string; commentId: string }
```

### `ValidateRoutes`

Compile-time check that every route with `:param` in its path has a `params` schema. Produces an intersection with `{ error: "Path contains params but no params schema was provided" }` on violating routes — surfacing a clear error in the IDE.

```typescript
import type { ValidateRoutes } from "@ivancerovina/contracts";

type Check = ValidateRoutes<typeof TaskContract.routes>;
// Clean — all parameterized routes have params schemas
```

## Pagination

Two helpers that return Zod schemas with `z.coerce.number()` (accepts strings from query params):

```typescript
import { offsetPagination, cursorPagination } from "@ivancerovina/contracts";

// Offset: { page: number, limit: number }
const offset = offsetPagination();                    // defaults: page=1, limit=25
const offset2 = offsetPagination({ page: 1, limit: 50 });

// Cursor: { cursor?: string, limit: number }
const cursor = cursorPagination();                    // default limit=25
const cursor2 = cursorPagination({ limit: 100 });
```

Compose with `.extend()` to add route-specific filters:

```typescript
query: offsetPagination().extend({
  status: z.enum(["active", "archived"]).optional(),
}),
```

## Response envelope schemas

Every API response follows a standard envelope. These helpers build Zod schemas for it:

```typescript
import {
  createSuccessSchema,
  createErrorSchema,
  createRawSchema,
  errorEnvelopeSchema,
} from "@ivancerovina/contracts";

// Success envelope: { success: true, data: T, requestId: string }
const success = createSuccessSchema(taskSchema);

// Error envelope: { success: false, error: { message, code }, requestId: string }
const error = createErrorSchema();

// Discriminated union of both (for full response validation)
const raw = createRawSchema(taskSchema);

// The error envelope is also exported as a constant
errorEnvelopeSchema; // z.ZodObject<...>
```

## HTTP client

A `fetch`-based client that binds to contracts for full type safety. Available from `@ivancerovina/contracts/client`.

```typescript
import { createHttpClient } from "@ivancerovina/contracts/client";
import { TaskContract } from "./task.contract";

const client = createHttpClient({
  baseUrl: "/api",
  headers: { "X-Custom": "value" },           // static headers
  // headers: () => ({ Authorization: `Bearer ${token}` }), // or dynamic
  // fetch: customFetch,                       // optional fetch override
});

const tasks = client.use(TaskContract);
```

### Making requests

The scoped client exposes `.get()`, `.post()`, `.patch()`, `.put()`, `.delete()`. Route names autocomplete to only routes matching that HTTP method.

```typescript
// GET — no options needed (query is optional)
const list = await tasks.get("listTasks", {
  query: { status: "todo", page: 1, limit: 10 },
});
// list: { tasks: Task[], total: number }

// GET with params
const task = await tasks.get("getTask", {
  params: { taskId: "abc-123" },
});
// task: { id, title, status, ... }

// POST with body
const created = await tasks.post("createTask", {
  body: { title: "New task" },
});

// Routes with no params/query/body don't require options
await tasks.get("listTasks");
```

### Input types

The client uses `z.input` for `body` and `query` (what the caller provides before server-side parsing). This means fields with `.default()` are optional in client calls:

```typescript
// Schema: z.object({ assigneeId: z.string().nullable().default(null) })
// Server output type: { assigneeId: string | null }  (always present)
// Client input type:  { assigneeId?: string | null }  (optional — server applies default)

await tasks.post("createTask", {
  body: { title: "Just a title" }, // assigneeId not required
});
```

### Error handling

Failed requests throw `ContractError` with structured fields:

```typescript
import { ContractError } from "@ivancerovina/contracts/client";

try {
  await tasks.get("getTask", { params: { taskId: "nope" } });
} catch (err) {
  if (err instanceof ContractError) {
    err.message; // "Task not found"
    err.code;    // "TASK_NOT_FOUND"
    err.status;  // 404
  }
}
```

## WebSocket namespaces

Contracts can declare WebSocket event schemas alongside HTTP routes:

```typescript
const contract = createContract({
  // ...routes...

  ws: [
    {
      namespace: "/tasks",
      serverEvents: {
        taskCreated: taskSchema,
        taskUpdated: taskSchema,
      },
      clientEvents: {
        subscribeToProject: z.object({ projectId: z.string().uuid() }),
      },
    },
  ],
});
```

- `serverEvents` — payloads the server sends to clients
- `clientEvents` — payloads clients send to the server

## File structure

```
src/
  index.ts              # Main entry — re-exports everything
  create-contract.ts    # createContract() implementation
  types.ts              # All type definitions and Infer* helpers
  response.ts           # Response envelope Zod schemas
  pagination.ts         # offsetPagination(), cursorPagination()
  client/
    index.ts            # Client entry — re-exports client
    http-client.ts      # createHttpClient(), ContractError
```

## Scripts

```bash
pnpm build       # Build with tsdown
pnpm dev         # Watch mode
pnpm lint        # Biome check
```