# ADR 0005 — The typed client is inferred from the contract

> Source: https://github.com/max-listov/stitchkit/blob/master/docs/decisions/0005-typed-client.md

---

# ADR 0005 — The typed client is inferred from the contract

- **Status:** Accepted
- **Date:** 2025-05 (core), 2026-05 (pilot fixes)

## Context

One source project hand-wrote its frontend client — a fetch wrapper plus
per-endpoint hooks in separate files. The contract was not used for client-side
inference, so the client could silently drift from the server.

Another prototype showed the alternative: a `TypedClient<C>` type that derives
the entire client API from the contract, with one typed function per endpoint.

## Decision

The typed client lives in the core. `createClient` / `createClients` build a
fully typed client from a contract with no hand-written per-endpoint code;
`createHttpClient` is the Ky-based transport adapter underneath (cookie auth,
SSR cookie forwarding, error parsing, transport retry).

Putting this through a real frontend (the pilot migration) exposed three subtle
defects, all now fixed:

- **Arguments are typed with `z.input`, not `z.output`.** The client *sends*
  pre-parse input; the server parses it to output. A field with `.default()`,
  `.coerce()` or `.transform()` differs between the two. Using the output type
  made server-defaulted fields *required* on the caller. `EndpointArgs` uses
  `z.input`; the handler's `ctx.input` keeps the output type.
- **The empty case is `{}`, never `Record<string, never>`.** The latter carries
  an index signature that poisons every intersected field to `never`, collapsing
  the whole argument type. `{} & X = X`. (`{}` was later written as `unknown`,
  which has the same intersection identity and is lint-clean.)
- **`defineContract<const T>`** preserves string literals (`multipart`,
  `method`, `path`). Without `const`, `multipart: 'file'` widened to `string`
  and the multipart argument type degraded to an index signature.

A per-endpoint `timeout` was also added: a slow synchronous endpoint (AI
generation) declares its own client timeout once, in the contract.

## Consequences

- Any contract yields a fully typed client; the transports cannot drift from it.
- Server-defaulted input fields are optional for the caller, as they should be.
- The client never needs an `as` cast to satisfy a contract method signature.
