Start here

Mental model

Separate declarations, implementations, adapters, clients, and transports so each runtime owns only the work it can perform correctly.

Start with a contract, not a server. The contract describes what crosses a boundary. A server implementation supplies behavior for the selected contract. An adapter translates between that portable implementation and a host. A client constructs typed calls, while its transport decides how those calls reach the server.

Follow one call in each direction

Client request lifecycle

  1. 01

    Select a route

    The client leaf already knows its contract key, method, path, and declared representations.

    TypeScript
    api.users.byId({ params: { id } })
  2. 02

    Encode and enrich

    Codecs encode application values; client context and middleware may add headers or reject the call.

    TypeScript
    codec.encode → middleware → transport
  3. 03

    Cross one transport

    Fetch, in-process, MessagePort, or WebSocket performs exactly one invocation.

    TypeScript
    transport(request)
  4. 04

    Decode the response

    Declared headers and bodies become a status-discriminated result; unknown statuses reject.

    TypeScript
    if (result.status === 200) result.body

Server request lifecycle

  1. 01

    Route in the host

    Native adapters use the framework router; catch-all adapters use the shared route matcher.

    TypeScript
    GET /api/users/:id
  2. 02

    Decode declared fields

    Parameters, query, headers, and body are processed in order and stop on failure.

    TypeScript
    params → query → headers → body
  3. 03

    Create context and run

    Request context and scoped middleware wrap the selected exhaustive handler.

    TypeScript
    context → middleware → handler
  4. 04

    Validate and write

    The declared status selects the response representation before the adapter writes it.

    TypeScript
    response(200, body) → host response

Schemas have a direction

An ordinary request schema accepts the client’s wire-shaped value and exposes its validated output to the server handler. An ordinary response schema accepts the handler value, validates or transforms it on the server, and sends that output to the client. The client does not rerun the response schema.

Use codec(wireSchema, applicationSchema, { encode, decode }) when client and server should both work with a richer application value such as Date while the transport carries a string. Use type-only request.json<T>() and response.json<T>() when native JSON parsing and static types are enough; a type argument does not add runtime validation.

Selections are real contract nodes

The root contract, every mounted router, and every route expose immutable $contract metadata. Pass any selection directly to createClient() or server.implement(selection, handlers). Selections retain inherited paths, parameter schemas, errors, and structural keys.

There is no client builder or client composition lifecycle. Create clients from the selections a module needs, and use normal module boundaries for code splitting. Server composition remains explicit and exhaustive because every deployable implementation must cover its selected routes.

Native state stays at the adapter boundary

Portable handlers do not receive a Fetch request, Express request, Hono context, or framework event. When a handler needs native state, create the server definition with that adapter’s typed context() helper and expose only the application context your handlers need.

src/apiboundary map
  • api

This keeps framework imports out of the contract and makes an incompatible mount fail during setup. Continue with contracts, then server implementations or typed clients.