# API contracts

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

A [contract](https://hulla.dev/docs/api/mental-model.md) 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.

## Declare a small API

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

```ts filename="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.

## Keys name code; paths name HTTP

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

| Contract key      | HTTP endpoint             |
| ----------------- | ------------------------- |
| `health`          | `GET /api/health`         |
| `projects.byId`   | `GET /api/projects/:id`   |
| `projects.rename` | `PATCH /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.

## Request fields are opt-in

A route accepts only what it declares:

```ts filename="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.

## Responses are concrete outcomes

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

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

That declaration becomes a discriminated client result:

```ts filename="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](https://hulla.dev/docs/api/core/errors.md).

## Choose how much happens at runtime

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

### Type-only: trust the producer

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

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

```ts
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](https://hulla.dev/docs/api/core/value-round-trips.md) for complete codec examples and [Request and response data](https://hulla.dev/docs/api/core/request-transport.md) for parsing rules.

## Implement or call the contract

The contract is now ready for two independent jobs:

- [Implementing the server](https://hulla.dev/docs/api/core/servers.md) binds exhaustive handlers and mounts them in a host.
- [Calling the API](https://hulla.dev/docs/api/core/clients.md) selects routes and crosses a client connection.

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