feat(hono): compile-time wire-string check for params/query schemas - #20
Closed
christensena wants to merge 8 commits into
Closed
feat(hono): compile-time wire-string check for params/query schemas#20christensena wants to merge 8 commits into
christensena wants to merge 8 commits into
Conversation
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
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,
-- Claude |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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