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
- 01
Select a route
The client leaf already knows its contract key, method, path, and declared representations.
TypeScriptapi.users.byId({ params: { id } }) - 02
Encode and enrich
Codecs encode application values; client context and middleware may add headers or reject the call.
TypeScriptcodec.encode → middleware → transport - 03
Cross one transport
Fetch, in-process, MessagePort, or WebSocket performs exactly one invocation.
TypeScripttransport(request) - 04
Decode the response
Declared headers and bodies become a status-discriminated result; unknown statuses reject.
TypeScriptif (result.status === 200) result.body
Server request lifecycle
- 01
Route in the host
Native adapters use the framework router; catch-all adapters use the shared route matcher.
TypeScriptGET /api/users/:id - 02
Decode declared fields
Parameters, query, headers, and body are processed in order and stop on failure.
TypeScriptparams → query → headers → body - 03
Create context and run
Request context and scoped middleware wrap the selected exhaustive handler.
TypeScriptcontext → middleware → handler - 04
Validate and write
The declared status selects the response representation before the adapter writes it.
TypeScriptresponse(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.
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.