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.

Shared moduleContractMethods, paths, request fields, statuses, representations, errors, and optional schemas.
types every stage below
  1. Caller
    Typed clientcreateClient()

    Builds the call and narrows the returned status.

  2. Boundary
    Transporttransport(request)

    Carries one portable invocation to the server side.

  3. Host
    Adapteradapter.mount()

    Connects native routing, context, reads, and writes.

  4. Server
    Implementationimplement()

    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.

FileRuntimePut this here
src/api/contract.tsSharedPaths, inputs, statuses, bodies, errors, schemas, and codecs
src/api/implementation.tsServer onlyHandlers, services, authorization, and server middleware
src/api/client.tsCallercreateClient(), client middleware, and the selected transport
Framework route or server entryHost onlyAdapter 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.

  1. 1Select a route

    The client knows the method, path, accepted fields, and possible responses.

  2. 2Prepare the call

    Client middleware adds auth headers, tracing, or cancellation.

  3. 3Cross the boundary

    The selected transport carries the call. The adapter connects it to host routing and context.

  4. 4Validate and handle

    The server decodes request fields, runs middleware, and returns a declared response or error.

  5. 5Read a typed result

    Check the response status to narrow the body type.

src/features/projects.ts
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…ChooseWhat stays in your application
Calls inside a trusted server runtimeIn-processServer rendering, jobs, and tests; validation and middleware still run.
HTTP between browsers or servicesFetchOrigin and deployment; the adapter matches Web requests.
An existing framework routerA native adapterRouting, hooks, parsed bodies, response writing, and request context.
A worker, desktop bridge, or persistent connectionMessagePort or WebSocketThe 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-2xx responses 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.