Skip to main content

Overview

@contract-kit/client provides type-safe client adapters for making contract-based HTTP requests. It generates fully typed API clients from your contracts with automatic validation and error handling.
Prefer the contract-kit meta package for new projects. It re-exports @contract-kit/client along with core, application, ports, and more.

Installation

npm install @contract-kit/client @contract-kit/core zod

Quick Start

Creating a Client

import { createClient } from "@contract-kit/client";

export const apiClient = createClient({
  baseUrl: "https://api.example.com",
  headers: async () => ({
    "x-app-version": "1.0",
    Authorization: `Bearer ${getToken()}`,
  }),
});

Making Requests

import { apiClient } from "@/lib/api-client";
import { getTodo, createTodo, listTodos } from "@/contracts/todos";

// GET request with path params
const todo = await apiClient
  .endpoint(getTodo)
  .call({ path: { id: "123" } });

// POST request with body
const newTodo = await apiClient
  .endpoint(createTodo)
  .call({ body: { title: "Learn Contract Kit" } });

// GET request with query params
const todos = await apiClient
  .endpoint(listTodos)
  .call({ query: { completed: false, limit: 10 } });

Configuration

Client Options

const client = createClient({
  // Required: Base URL for all requests
  baseUrl: string;
  
  // Optional: Headers function (can be async)
  headers?: () => Promise<Record<string, string>> | Record<string, string>;
  
  // Optional: Custom fetch implementation
  fetch?: typeof fetch;
});

Dynamic Headers

Headers can be static or dynamically computed:
// Static headers
const client = createClient({
  baseUrl: "https://api.example.com",
  headers: () => ({
    "Content-Type": "application/json",
  }),
});

// Dynamic headers with authentication
const client = createClient({
  baseUrl: "https://api.example.com",
  headers: async () => {
    const token = await getAuthToken();
    return {
      Authorization: `Bearer ${token}`,
      "x-request-id": crypto.randomUUID(),
    };
  },
});

Custom Fetch

Use a custom fetch implementation for advanced scenarios:
import { createClient } from "@contract-kit/client";

const client = createClient({
  baseUrl: "https://api.example.com",
  fetch: async (url, init) => {
    console.log("Fetching:", url);
    const response = await fetch(url, init);
    console.log("Response:", response.status);
    return response;
  },
});

Making Requests

GET Requests

import { getTodo } from "@/contracts/todos";

// With path parameters
const todo = await apiClient
  .endpoint(getTodo)
  .call({ path: { id: "123" } });

// With query parameters
const todos = await apiClient
  .endpoint(listTodos)
  .call({
    query: {
      completed: false,
      limit: 10,
      offset: 0,
    },
  });

POST Requests

import { createTodo } from "@/contracts/todos";

const newTodo = await apiClient
  .endpoint(createTodo)
  .call({
    body: {
      title: "Learn Contract Kit",
      description: "Read all the docs",
    },
  });

PUT/PATCH Requests

import { updateTodo } from "@/contracts/todos";

const updatedTodo = await apiClient
  .endpoint(updateTodo)
  .call({
    path: { id: "123" },
    body: {
      title: "Updated title",
      completed: true,
    },
  });

DELETE Requests

import { deleteTodo } from "@/contracts/todos";

await apiClient
  .endpoint(deleteTodo)
  .call({ path: { id: "123" } });

Error Handling

ContractError

All client errors are instances of ContractError:
import { ContractError } from "@contract-kit/client";

try {
  const todo = await apiClient
    .endpoint(getTodo)
    .call({ path: { id: "123" } });
} catch (error) {
  if (error instanceof ContractError) {
    console.log(error.status);   // HTTP status code
    console.log(error.details);  // Error response body (validated)
    console.log(error.message);  // Error message
  }
}

Handling Specific Error Codes

try {
  const todo = await apiClient
    .endpoint(getTodo)
    .call({ path: { id: "123" } });
} catch (error) {
  if (error instanceof ContractError) {
    switch (error.status) {
      case 404:
        console.log("Todo not found");
        break;
      case 401:
        console.log("Unauthorized");
        break;
      case 500:
        console.log("Server error");
        break;
      default:
        console.log("Unknown error:", error.status);
    }
  }
}

Type-Safe Error Details

When your contract defines error schemas, the error details are fully typed:
// Contract with error schema
const getTodo = todos
  .get("/api/todos/:id")
  .path(z.object({ id: z.string() }))
  .response(200, TodoSchema)
  .errors({
    404: z.object({
      code: z.literal("TODO_NOT_FOUND"),
      message: z.string(),
    }),
  });

// Error handling with typed details
try {
  const todo = await apiClient.endpoint(getTodo).call({ path: { id: "123" } });
} catch (error) {
  if (error instanceof ContractError && error.status === 404) {
    // error.details is typed as { code: "TODO_NOT_FOUND", message: string }
    console.log(error.details.code);     // "TODO_NOT_FOUND"
    console.log(error.details.message);  // string
  }
}

Creating API Wrappers

Organize your API calls into reusable modules:
// features/todos/api.ts
import { apiClient } from "@/lib/api-client";
import { getTodo, createTodo, updateTodo, deleteTodo, listTodos } from "@/contracts/todos";

export const todosApi = {
  get: (id: string) =>
    apiClient.endpoint(getTodo).call({ path: { id } }),

  list: (filters?: { completed?: boolean; limit?: number }) =>
    apiClient.endpoint(listTodos).call({ query: filters }),

  create: (data: { title: string; description?: string }) =>
    apiClient.endpoint(createTodo).call({ body: data }),

  update: (id: string, data: { title?: string; completed?: boolean }) =>
    apiClient.endpoint(updateTodo).call({ path: { id }, body: data }),

  delete: (id: string) =>
    apiClient.endpoint(deleteTodo).call({ path: { id } }),
};

// Usage
const todos = await todosApi.list({ completed: false });
const newTodo = await todosApi.create({ title: "New todo" });

React Integration

Use with React Query for powerful data fetching:
import { useQuery, useMutation } from "@tanstack/react-query";
import { todosApi } from "@/features/todos/api";

function TodosList() {
  const { data, isLoading } = useQuery({
    queryKey: ["todos"],
    queryFn: () => todosApi.list(),
  });

  const createMutation = useMutation({
    mutationFn: todosApi.create,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      {data?.todos.map(todo => (
        <div key={todo.id}>{todo.title}</div>
      ))}
      <button onClick={() => createMutation.mutate({ title: "New todo" })}>
        Add Todo
      </button>
    </div>
  );
}
See @contract-kit/react-query for more powerful integrations.

API Reference

createClient(config)

Creates an HTTP client instance.
const client = createClient({
  baseUrl: string;
  headers?: () => Promise<Record<string, string>> | Record<string, string>;
  fetch?: typeof fetch;
});

client.endpoint(contract)

Creates a typed endpoint for a contract.
const endpoint = client.endpoint(getTodo);
const result = await endpoint.call({ path: { id: "123" } });

Type Exports

import { ContractError } from "@contract-kit/client";
import type {
  CallArgs,
  Client,
  ClientConfig,
  Endpoint,
  EndpointCallArgs,
  InferBody,
  InferPathParams,
  InferQuery,
  InferSuccessResponse,
} from "@contract-kit/client";

Best Practices

Organize API calls into domain-specific modules (e.g., todosApi, usersApi) for better organization.
Create a centralized error handling strategy using ContractError to handle common error codes.
Compute headers dynamically to include fresh authentication tokens on every request.
Let TypeScript infer types from contracts instead of manually typing API responses.

Next Steps