Skip to content

feat(hono): compile-time wire-string check for params/query schemas - #20

Closed
christensena wants to merge 8 commits into
mainfrom
feat/codegen-route-wrapped
Closed

feat(hono): compile-time wire-string check for params/query schemas#20
christensena wants to merge 8 commits into
mainfrom
feat/codegen-route-wrapped

Conversation

@christensena

Copy link
Copy Markdown
Owner

Params and query values reach the server as raw strings, so a bare
z.number()/z.boolean() type-checks but 400s on every request. route()
now rejects such schemas at compile time: a WireCheck type errors on any
params/query value whose input type admits neither a string nor an array
of strings.

z.coerce.number() is structurally identical to z.number() at the
type level, so the blessed idiom becomes z.coerce.number<number|string>()
— the input type declares the wire form, which is what the check (and the
client's argument types) can see. z.stringbool() covers booleans;
z.coerce.boolean() stays out (JS truthiness turns "false" into true).

Docs on route()/queryArray() and a README section cover the trap; the
example API demonstrates both idioms.

Closes #2

Co-Authored-By: Claude Fable 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_0197TQhAzcfxR5LQigesnfmv

christensena and others added 8 commits August 30, 2026 21:52
Params and query values reach the server as raw strings, so a bare
z.number()/z.boolean() type-checks but 400s on every request. route()
now rejects such schemas at compile time: a WireCheck type errors on any
params/query value whose input type admits neither a string nor an array
of strings.

z.coerce.number<number>() is structurally identical to z.number() at the
type level, so the blessed idiom becomes z.coerce.number<number|string>()
— the input type declares the wire form, which is what the check (and the
client's argument types) can see. z.stringbool() covers booleans;
z.coerce.boolean() stays out (JS truthiness turns "false" into true).

Docs on route()/queryArray() and a README section cover the trap; the
example API demonstrates both idioms.

Closes #2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197TQhAzcfxR5LQigesnfmv
Adopt z.coerce.number<number | `${number}`>() as the blessed idiom for
coerced params/query values: the template-literal input type admits only
numeric strings, so wire-form client args reject 'abc' while the wire
check still sees that strings are welcome.

Flip @zodapi/client's encodeRequests default to true: request args are
typed z.output (Date for codecs, number for coerced params) and
codec-bearing data is z.encode'd before sending. Keys the input side
lets the caller omit (.default()/.optional()) stay omittable in output
mode, so defaulted query params don't become required args. Pass
encodeRequests: false for wire-form z.input args.

The example gains numeric user ids served by coerced number params.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197TQhAzcfxR5LQigesnfmv
…ns body only

Params, query, and header values end up as strings on the wire no matter
what, so the caller now always supplies them decoded (z.output typing,
input-side optionality preserved) and codec-bearing values are always
z.encode'd to their wire form — per key for object schemas, so a codec
(z.stringbool(), a date codec) can sit next to a one-way transform like
queryArray() that whole-schema z.encode would reject.

encodeRequests narrows to the request body and returns to its previous
default: off (wire-form z.input bodies); true flips the body arg to
decoded z.output, encoded before sending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197TQhAzcfxR5LQigesnfmv
The template-literal type argument was only ever a workaround for
input-typed client args; with params/query args now typed from z.output
it leaks an implementation detail into contract code for no benefit.
Plain z.coerce.number() (input `unknown`) passes the wire check as-is —
only the narrowed z.coerce.number<number>() form, indistinguishable from
z.number(), remains rejected, now covered by its own type test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197TQhAzcfxR5LQigesnfmv
Replace the R & ParamsCheck<R> & WireCheck<R> parameter intersection
with a conditional ValidatedRouteConfig<R>: R itself when the checks
pass, only the small error object when they fail. The inferred config
type no longer appears in the failure branch, so a bad route() call
errors with the one-line message and offending keys instead of dumping
the whole zod-typed config twice. Verified on both tsc 7 and the
typescript-6 checker; a type test pins the collapsed parameter shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197TQhAzcfxR5LQigesnfmv
…deps

route() came from @zodapi/hono, whose createRoute import pulled the
@hono/zod-openapi barrel — hono, zod-to-openapi, openapi3-ts, yaml — into
every package holding a contract. Bundlers that do not tree-shake within
modules shipped all of it to clients that never call it, and sideEffects:
false could not help: the barrel runs extendZodWithOpenApi(z) at import
time. The eight lines of createRoute that route() actually used are
inlined, keeping getRoutingPath non-enumerable so it stays out of spreads
and the generated document.

ZodapiRouteConfig is now expressed in zod types rather than re-exporting
hono's RouteConfig. Request and response shapes are exact — they drive
every inference; the OpenAPI documentation fields are typed loosely, and
anything they let through app.openapi() still catches. method drops OAS
3.2's 'query' to match RouteDef['method'], which drives the client.

BREAKING CHANGE: @zodapi/hono is now server-only, exporting createApp()
plus OpenAPIHono and createRoute. Import route, validationErrorResponse,
ZodapiRoute, ZodapiRouteConfig, queryArray, ValidationError,
PROBLEM_JSON_CONTENT_TYPE and ZODAPI_VALIDATION_TYPE from @zodapi/core,
and z from zod. The dropped z re-export also carried the .openapi()
prototype method — use zod's .meta({ id: 'User' }), which produces the
same component and $ref.

The generated OpenAPI document is byte-identical before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P4oNyS84DzZeNFzLGcw7rQ
…rver

Generated contracts emitted a plain object `satisfies RouteDef`, which
createClient() accepts but app.openapi() does not — it needs the
getRoutingPath() that route() attaches. Emitting route({ ... }) makes a
contract generated from an OpenAPI document usable on both sides, and
drops the GeneratedRoute helper type: the operation documentation fields
it carried are part of the config route() already accepts.

Numeric and boolean path and query parameters are wrapped in wireNumber()
/ wireBoolean(), new in @zodapi/core. Without them route()'s wire-string
check rejects the output at compile time, since a raw query string never
satisfies z.int(). They preprocess the string and leave the declared
schema alone, so the document generated from the contract still equals
the source document — z.coerce.number() and z.stringbool() would not: a
bare z.coerce.number() documents itself as type: ["number", "null"] and
z.stringbool() as type: "string". Hand-written contracts have no
specification to reproduce and keep using the coercing schemas directly.

Two consequences of going through route(). An operation whose spec
declares no 400 now gets the zodapi ValidationError response injected,
and one declaring its own keeps it with the problem+json content merged
alongside; previously the spec's responses were emitted verbatim. And a
templated path whose parameter the spec never declares now throws when
the generated module is imported, rather than producing a route that
400s on every request. Both are covered by a new fixture generated from
a spec shaped like a non-zodapi backend's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P4oNyS84DzZeNFzLGcw7rQ
@christensena

Copy link
Copy Markdown
Owner Author

Closing as superseded. The work described here — the compile-time wire-string check for params/query schemas — is what #19 carries.

Note that this PR's branch name, feat/codegen-route-wrapped, was reused for a new stack pushed today, so the diff shown above is no longer the change described in the body. The live PRs are:

-- Claude

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tighten string coercion for params/query schemas (bare z.number()/z.boolean() silently 400)

1 participant