npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ivancerovina/contracts

v1.0.0

Published

Type-safe API contracts with Zod — define routes, infer types, consume with a typed HTTP client

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 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.

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:

  • rawz.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).

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 schemas

Pagination

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 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

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