@ivancerovina/contracts
v1.0.0
Published
Type-safe API contracts with Zod — define routes, infer types, consume with a typed HTTP client
Maintainers
Readme
@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
pnpm add @ivancerovina/contracts zodEntry 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.
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 shapeerror— 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).
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:
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.
import type { ValidateRoutes } from "@ivancerovina/contracts";
type Check = ValidateRoutes<typeof TaskContract.routes>;
// Clean — all parameterized routes have params schemasPagination
Two helpers that return Zod schemas with z.coerce.number() (accepts strings from query params):
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:
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:
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.
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.
// 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:
// 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:
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:
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 clientsclientEvents— 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(), ContractErrorScripts
pnpm build # Build with tsdown
pnpm dev # Watch mode
pnpm lint # Biome check