Fundamentals

API contracts

Define the routes, inputs, and responses that your server and client share.

On this page

A contract lists your API’s routes and describes what each accepts and returns. The server uses it to check requests. The client uses it to type your calls.

Keep the contract in a shared file so both can import it. The handlers live separately on the server.

This contract has a health check, a project read, and a project rename:

src/api/contract.ts
import { defineContract, request, response, route, router } from '@hulla/api'
import { z } from 'zod'

const project = z.object({
  id: z.string(),
  name: z.string(),
})

const projectParams = z.object({ id: z.string() })
const notFound = response.json(z.object({ message: z.string() }))

export const contract = defineContract({
  basePath: '/api',
  routes: {
    health: route.get('/health', {
      responses: { 200: response.text(z.literal('ok')) },
    }),
    projects: router('/projects', {
      routes: {
        byId: route.get('/:id', {
          params: projectParams,
          responses: {
            200: response.json(project),
            404: notFound,
          },
        }),
        rename: route.patch('/:id', {
          params: projectParams,
          body: request.json(z.object({ name: z.string().min(1) })),
          responses: {
            200: response.json(project),
            404: notFound,
          },
        }),
      },
    }),
  },
})

Nothing is listening yet. defineContract() records an immutable boundary that later clients, servers, adapters, and tooling can select.

The object tree becomes the typed client and handler tree. The path pieces become the URL.

Contract keyHTTP endpoint
healthGET /api/health
projects.byIdGET /api/projects/:id
projects.renamePATCH /api/projects/:id

Renaming byId changes api.projects.byId(...); it does not change the URL. Changing /:id changes the URL; it does not choose the TypeScript property name.

Every :param needs a matching key in an object-shaped params schema. Router parameters flow into child routes, and a route may add its own.

Paths describe route structure, not pre-encoded URLs. Keep query strings and fragments out of them, and put dynamic values in parameters. The client handles encoding once.

A route accepts only what it declares:

src/api/contract.ts
rename: route.patch('/:id', {
  params: z.object({ id: z.string() }),
  body: request.json(z.object({ name: z.string().min(1) })),
  responses: {/* ... */},
})

Use params, query, and headers for those HTTP fields. A body declaration also chooses its representation:

  • request.json() for JSON;
  • request.text() for text;
  • request.bytes() for binary data;
  • request.formData() for forms and files.

GET routes cannot declare a body. If a route does not declare a field, the typed client cannot pass it and the handler does not receive it.

List every status the handler may intentionally return. A status is never inferred from the body.

src/api/contract.ts
responses: {
  200: response.json(project),
  404: response.json(z.object({ message: z.string() })),
}

That declaration becomes a discriminated client result:

src/features/project-page.ts
const result = await api.projects.byId({ params: { id } })

if (result.status === 200) {
  renderProject(result.body)
} else {
  showNotFound(result.body.message)
}

The 200 body and 404 body cannot be mixed accidentally. Reusable named application failures belong in defineErrors(); see Errors.

You make this choice for each declared value—not once for the whole API.

Type-only: trust the producer

TypeScript
response.json<Project>()

This gives both sides a TypeScript type and uses native JSON conversion. It does not check that an application value matches Project at runtime.

Use it for data already guaranteed by a trusted internal boundary, or when another layer owns validation.

Standard Schema: validate at the boundary

TypeScript
response.json(z.object({ id: z.string(), name: z.string() }))

Pass Zod, Valibot, ArkType, Effect Schema, or another Standard Schema directly. Request schemas validate the decoded value before the handler; response schemas validate or transform the handler value before it is sent.

The client does not rerun ordinary response schemas. It consumes the server’s validated output.

Codec: use different application and wire values

TypeScript
response.json(
  codec(dateWireSchema, dateApplicationSchema, {
    encode: (date) => date.toISOString(),
    decode: (value) => new Date(value),
  })
)

Use a codec when both sides should receive a richer application value than the transport can carry directly. Dates, branded IDs, and binary encodings are common examples.

Read Validation and serialization for complete codec examples and Request and response data for parsing rules.

The contract is now ready for two independent jobs:

Both import the contract. Neither needs to import the other.