A zodios-style, contract-first API toolkit for zod 4 + Hono + OpenAPI 3.1, assembled on top of
@hono/zod-openapi rather than reinventing it. Routes are defined once (zod 4 schemas, OpenAPI-style
paths) and shared by an idiomatic Hono server, a generated OpenAPI 3.1 document, and a typed
fetch/axios client with optional runtime validation and zodios-style error guards.
| Package | What it is |
|---|---|
@zodapi/core |
Contract types, the fixed ValidationError 400 problem-details shape, ApiError + typed error guards (isErrorFromRoute, matchErrorByStatus, isAxiosErrorFromRoute, ...), pluggable error decoders (problemDetails, decodersFor). No HTTP deps. |
@zodapi/hono |
Thin preset over @hono/zod-openapi: createApp() (fixed 400 shape via defaultHook, a[]= query normalization) and route() (createRoute + injected 400 + body.required default + alias). |
@zodapi/client |
createClient(routes, ...): path- or alias-addressed typed calls over fetch (default) or axios (@zodapi/client/axios), with validate: 'none' | 'request' | 'response' | 'both'. |
@zodapi/codegen |
zodapi-codegen openapi.json -o contract.ts: generates a zodapi contract (zod schemas + route objects) from an OpenAPI 3.1 document, for backends not written in TypeScript. |
examples/api is a shared contract, examples/app a runnable server + client demo.
Contract (shared):
import { route, z } from '@zodapi/hono'
export const getUser = route({
alias: 'getUser',
method: 'get',
path: '/users/{id}',
request: { params: z.object({ id: z.string() }) },
responses: {
200: { description: 'The user', content: { 'application/json': { schema: User } } },
404: { description: 'Missing', content: { 'application/json': { schema: NotFound } } },
},
})
export const routes = [getUser /* ... */] as constServer (idiomatic Hono — c.req.valid, RPC types intact):
import { createApp } from '@zodapi/hono'
const app = createApp()
.openapi(getUser, (c) => {
const { id } = c.req.valid('param')
// ...
return c.json(user, 200)
})
.doc31('/openapi.json', { openapi: '3.1.0', info: { title: 'API', version: '1.0.0' } })Validation failures return 400 as an RFC 9457 problem
(Content-Type: application/problem+json, also documented as the ValidationError component on
every route):
{ "type": "urn:zodapi:validation", "status": 400, "target": "query", "issues": [ ... zod issues ... ] }Client:
import { ValidationApiError, createClient, matchErrorByStatus } from '@zodapi/client'
import { z } from 'zod'
const client = createClient(routes, { baseUrl: 'http://localhost:3000' })
const user = await client.get('/users/{id}', { params: { id: '1' } }) // by path
const same = await client.getUser({ params: { id: '1' } }) // by alias
try {
await client.createUser({ body: newUser })
} catch (err) {
if (err instanceof ValidationApiError) {
z.flattenError(err.error).fieldErrors // a real ZodError, revived from the server's issues
} else if (matchErrorByStatus(createUser, err, 409)) {
err.data.error.existingId // fully typed, runtime-checked with zod
}
}Server-side validation failures are decoded into ValidationApiError carrying a real z.ZodError,
so client- and server-side failures share one handling path. Non-zodapi backends that speak
RFC 9457 (ASP.NET, Spring, ...) plug in via decoders:
import { decodersFor } from '@zodapi/client'
import { problemFlavor, routes } from './generated-contract.js' // @zodapi/codegen emits the flavor
const client = createClient(routes, { baseUrl, decoders: decodersFor(problemFlavor) })Axios instead of fetch:
import { axiosAdapter } from '@zodapi/client/axios'
const client = createClient(routes, { baseUrl, adapter: axiosAdapter(axios.create()) })isAxiosErrorFromRoute(route, err) recognises declared error responses on a raw AxiosError
(zodios isErrorFromPath equivalent) for code not using the zodapi client.
When the server is not written in TypeScript, generate the contract from its OpenAPI 3.1 document instead of authoring it:
zodapi-codegen openapi.json -o contract.ts # or: import { generateContract } from '@zodapi/codegen'The generated file imports only zod and @zodapi/core: one exported const per
components/schemas entry (component name = const name, recursion via shape getters), one plain
RouteDef object per operation (operationId becomes the client alias; no alias without one),
and a routes tuple ready for createClient(routes). The spec's declared responses are taken
verbatim — nothing (like the zodapi 400) is injected. A problemFlavor const
('zodapi' | 'problem-details' | undefined, detected from the spec's error responses) is exported
for decodersFor(...). Output is unformatted; run your formatter over it.
Fidelity is enforced by a round-trip test: a comprehensive hand-written contract is serialized to
OpenAPI, fed through the generator, and the doc emitted from the generated contract must equal the
original. Not covered: webhooks, refs outside #/components/schemas, response headers, and
OpenAPI 3.0 documents (3.1 only).
- OpenAPI 3.1 only (
app.doc31). - Query arrays use
a[]=1&a[]=2. Declare them withqueryArray(item);createApp()strips the[]suffix at the edge (itsfetch), so plain repeated keys work too. The normalization does not apply when the app is mounted under another Hono app via.route(). - Errors throw. Non-2xx responses run through the error decoders first (server validation
failures throw
ValidationApiError, other recognised problem+json responsesProblemApiError); otherwise declared statuses throwApiError(narrow with the guards) and undeclared statusesUnexpectedResponseApiError. Decoding is keyed on theapplication/problem+jsonmedia type, so a400an API returns itself with plainapplication/jsonis not decoded — it throws a plainApiErrorlike any other declared status. Client-side validation failures throwRequestValidationError/ResponseValidationError. - Validation default is
'response'(2xx bodies parsed with the contract schema; error bodies are checked by the guards instead). request.body.requireddefaults totrueso a missing/mismatchedContent-Typeis a 400, not a silently skipped validation.
mise install # node + pnpm
pnpm install
pnpm build # all packages (topological)
pnpm test # vitest (build first)
pnpm typecheck # includes type-level assertions in test/*.test-d.ts
pnpm dev # example server on :3000 (PORT to override)
pnpm --filter @zodapi/example-app client-demo