Start here
Mental model
Follow a request from the typed client to its server handler and back.
On this page
Define a route once in a shared contract. The server adds a handler for it, and the client turns it into a typed function you can call.
A transport connects the client to the server. Use HTTP for browser requests or another service, and a direct call when both run in the same process.
- CallerTyped client
createClient()Builds the call and narrows the returned status.
- BoundaryTransport
transport(request)Carries one portable invocation to the server side.
- HostAdapter
adapter.mount()Connects native routing, context, reads, and writes.
- ServerImplementation
implement()Runs middleware and the exhaustive route handler.
Your application owns: business logic, persistence, authentication policy, caching, and deployment.
@hulla/api owns: contract execution, declared conversion, typed results, and boundary consistency.
| File | Runtime | Put this here |
|---|---|---|
src/api/contract.ts | Shared | Paths, inputs, statuses, bodies, errors, schemas, and codecs |
src/api/implementation.ts | Server only | Handlers, services, authorization, and server middleware |
src/api/client.ts | Caller | createClient(), client middleware, and the selected transport |
| Framework route or server entry | Host only | Adapter mounting and native request context |
Share the contract with frontend code or publish it as a package. Keep the implementation server-only: it can access databases, secrets, queues, and framework state.
- 1Select a route
The client knows the method, path, accepted fields, and possible responses.
- 2Prepare the call
Client middleware adds auth headers, tracing, or cancellation.
- 3Cross the boundary
The selected transport carries the call. The adapter connects it to host routing and context.
- 4Validate and handle
The server decodes request fields, runs middleware, and returns a declared response or error.
- 5Read a typed result
Check the response status to narrow the body type.
const result = await api.projects.byId({ params: { id } })
if (result.status === 200) {
renderProject(result.body)
} else {
showNotFound(result.body.message)
}There is no generated client and no hidden global router. The client is a typed view of the contract plus a transport.
| Your application needs… | Choose | What stays in your application |
|---|---|---|
| Calls inside a trusted server runtime | In-process | Server rendering, jobs, and tests; validation and middleware still run. |
| HTTP between browsers or services | Fetch | Origin and deployment; the adapter matches Web requests. |
| An existing framework router | A native adapter | Routing, hooks, parsed bodies, response writing, and request context. |
| A worker, desktop bridge, or persistent connection | MessagePort or WebSocket | The port or socket lifecycle. Client calls keep the same shape. |
Keep in-process clients on the server. They import the server implementation and must never enter browser code.
A request schema runs on the server after the transport decodes the request. A response schema runs on the server before the adapter sends the response.
The client does not rerun those ordinary schemas. If both sides should work with a richer value than the wire can carry—Date in memory and an ISO string in transit—use a codec with explicit encode and decode directions.
Type-only declarations skip application-shape validation altogether. They still use the selected representation, so malformed JSON remains malformed JSON; TypeScript alone does not make untrusted data safe.
- Keep framework imports, database clients, and secrets out of the contract module.
- Use ordinary functions when no contract boundary is needed.
- Treat declared non-
2xxresponses as expected values; reserve thrown failures for transport, schema, and unexpected runtime problems. - Select the smallest contract subtree a client or server module needs.
From here, learn how to author contracts, implement servers, or build typed clients. If you already know your boundary, jump directly to transports or server adapters.