Node + TypeScript API (Hono)

Pathrule3 Rules • 3 Memories • 1 Skill

An opinionated baseline for shipping Hono APIs on Node 22 with end-to-end type safety. It covers route chaining for RPC inference, Standard Schema validation at the edge of every handler, centralized error handling with HTTPException, and the conventions that keep the client and server in sync.

Suggested path map

Pathrule places each piece on the matching path, so your assistant only sees it where it belongs. This is the scoping you get on import; you can adjust it in your workspace.

/ workspace root
node-ts-api-hono-review
src/
Centralize errors through onError and HTTPException
Hono baseline: versions, runtime, and app wiring
RPC unknown-response footguns and fixes
routes/
Chain routes and export AppType for RPC
Validate every handler input with a schema validator
client/
Typed RPC client usage

Rules

3
Chain routes and export AppType for RPC/src/routesDefine routes as one chained expression and export typeof the chained app so the RPC client infers types instead of unknown.
1The Hono RPC client only sees routes that belong to a single chained expression. Break the chain and `hc<AppType>` resolves responses to `unknown`.
2 
3- Define handlers by chaining directly off `new Hono()`, for example `const routes = new Hono().get(...).post(...)`. Do not assign `app` and then call `app.get(...)` on later statements; reassigned, line-by-line definitions are not captured by `typeof`.
4- Mount sub-apps inline inside the same chain with `.route('/books', books)`, never as a standalone `app.route(...)` statement after the chain.
5- Export the route tree as a type at the top level: `export type AppType = typeof routes`. Import it with `import type { AppType }` on the client side.
6- Set `"strict": true` in the tsconfig of both the server and any client package. Hono RPC type inference depends on strict mode; without it inference silently degrades.
7- If you must split handlers out of the chain, use `factory.createHandlers()` from `hono/factory` so path-param and validator types still infer. Do not write Rails-style controller files that take a bare `Context`.
Validate every handler input with a schema validator/src/routeshighstrictGuard each handler with zValidator or sValidator and read only via c.req.valid(); never touch raw c.req.json() or query params.
1Untyped `c.req.json()` and raw query params are untrusted input and also produce no request types for the RPC client. Every handler reads validated data.
2 
3- Validate the relevant targets (`json`, `query`, `param`, `form`, `header`, `cookie`) with `zValidator` from `@hono/zod-validator`, or `sValidator` from `@hono/standard-validator` when the team wants library-agnostic schemas (Zod, Valibot, or ArkType via Standard Schema).
4- Read validated data only with `c.req.valid('json')` (and the matching target). Do not call `c.req.json()` / `c.req.query()` directly in a handler that has a validator.
5- Place the validator middleware before the handler in the chain so its types flow into both `c.req.valid()` and the inferred RPC request type.
6- Supply the third hook argument to return a structured failure before business logic runs: `zValidator('json', schema, (result, c) => { if (!result.success) return c.json({ error: result.error }, 400) })`. Returning the error from the hook with an explicit status keeps the 400 in the RPC response union.
7- Note `@hono/zod-validator` 0.8.x supports Zod 3 and 4 (`zod ^3.25.0 || ^4.0.0`); pin one Zod major across the workspace to avoid duplicate-instance validation errors.
Centralize errors through onError and HTTPException/srchighstrictThrow HTTPException for expected failures and serialize everything through a single root app.onError; never return ad-hoc error objects.
1Every failure returns one consistent shape, registered once on the root app.
2 
3- Throw `HTTPException` from `hono/http-exception` for expected failures, e.g. `throw new HTTPException(404, { message: 'Not found' })`. Do not scatter ad-hoc `c.json({ error }, 4xx)` shapes across handlers for control-flow errors.
4- Register exactly one `app.onError((err, c) => ...)` on the root app. For an `HTTPException`, return `err.getResponse()`; for anything else, return a generic 500 and do not leak `err.stack` or internal messages to the client.
5- Register `app.notFound(...)` once on the root app as well. Inside a handler, avoid `c.notFound()` when the route is consumed by the RPC client, because it makes the client response type `unknown` (see the RPC footguns memory).
6- Keep `onError` and `notFound` as the last wiring on the root app, after global middleware and after all routes are mounted.

Memories

3
Hono baseline: versions, runtime, and app wiring/srcPinned stack and the canonical app/server bootstrap for Hono on Node 22.
1This API runs Hono v4 (4.12.x line, latest as of mid-2026) on Node 22 LTS with TypeScript 5.4+. The route-param and RPC type inference depends on TS 5.4+ const type parameters, so do not downgrade TypeScript.
2 
3- Serve on Node with `@hono/node-server`'s `serve({ fetch: app.fetch, port })`. The same `app` deploys unchanged to Cloudflare Workers, Deno, or Bun because Hono targets Web Standards; keep handlers free of Node-only globals.
4- Keep `src/index.ts` thin: create the root app, attach global middleware (`logger`, `cors`, `secureHeaders`), mount feature routers via chained `.route()`, register `onError` + `notFound`, then `export default app` and `export type AppType`.
5- Return responses with `c.json()` / `c.text()` and read request-scoped state via typed `c.var` / `c.set()` context variables; never mutate Node req/res directly.
6- Order middleware deliberately: `logger` and `cors` outermost, then auth, then per-route validators, then the handler. Middleware runs top to bottom on the way in.
7 
8See /src/client for typed RPC client usage and /src for the RPC unknown-response footguns to avoid.
Typed RPC client usage/src/clientHow to consume the server's AppType with hc and the InferRequestType/InferResponseType helpers.
1The frontend and internal callers talk to the API through Hono's RPC client, which derives request and response types straight from `AppType` with no codegen step.
2 
3- Build the client with `import { hc } from 'hono/client'` and `const client = hc<AppType>(baseUrl)`. Import the server type as `import type { AppType }` so it is erased at build time.
4- Call endpoints as method chains, for example `await client.books[':id'].$get({ param: { id } })`. The result of `await res.json()` is typed from the handler's `c.json()` return value and is a union across the handler's status codes.
5- Use `InferRequestType<typeof client.books[':id'].$get>` and `InferResponseType<...>` to lift the request/response shapes into shared types (form state, react-query keys, etc.) without re-declaring them.
6- Narrow on `res.status` (or `res.ok`) before reading the body when a handler returns multiple status codes; each branch is independently typed.
7 
8See /src for the RPC unknown-response footguns memory if responses come back as `unknown`.
RPC unknown-response footguns and fixes/srcWhy hc responses degrade to unknown and the concrete fixes: chaining, c.json status codes, ApplyGlobalResponse, monorepo type-compile.
1When the RPC client types resolve to `unknown`, it is almost always a server-side or build-config issue, not a client bug. The recurring causes, in rough order of frequency:
2 
3- Route defined outside the chain: any `app.get(...)` written as a separate statement is invisible to `typeof routes`. Fold it back into the single chained expression.
4- `c.notFound()` inside a handler: it makes that route's response type `unknown` for the client. Return `c.json({ error: 'not found' }, 404)` with an explicit status instead, or augment `interface NotFoundResponse extends TypedResponse<...>` via module augmentation if you must keep `c.notFound()`.
5- Global error types are not auto-included: responses produced by `app.onError()` (and global middleware) are not inferred into the client by default. Merge them with the `ApplyGlobalResponse` type helper so the client union also carries the 500 shape, e.g. `type AppType = ApplyGlobalResponse<typeof app, { 500: { json: { error: string } } }>`.
6- Monorepo / turborepo regression: in workspace setups consuming the server as a package, Hono v4.11+ has shown the imported app type collapsing to `unknown` where v4.10.8 worked (honojs/hono#4638). Mitigations: ensure `"strict": true` in every package tsconfig, emit and consume proper `.d.ts` declarations across the workspace boundary, and prefer the compiled-client export below over importing the raw `typeof app` across packages.
7- IDE slowness on large apps: compile the client type once and re-export it rather than re-inferring per file: `export type Client = ReturnType<typeof hc<typeof app>>; export const hcWithType = (...args: Parameters<typeof hc>): Client => hc<typeof app>(...args)`.

Skills

1
node-ts-api-hono-review/rootPre-merge checklist for Hono API changes: RPC type safety, validation, errors, and runtime.
1---
2name: node-ts-api-hono-review
3description: Review checklist for Node + TypeScript Hono API changes. Run before merging any route, validator, error-handler, or RPC client change to keep types, validation, and error shapes consistent.
4---
5 
6# Node + TypeScript API (Hono) review
7 
8- [ ] Routes are one chained expression (`new Hono().get(...).post(...)`), not reassigned line by line, and `export type AppType = typeof routes` is present.
9- [ ] Sub-apps are mounted inline with `.route()` inside the chain, not as a standalone statement.
10- [ ] `"strict": true` is set in the tsconfig of both server and any client package.
11- [ ] Every handler reads input via `c.req.valid(...)` behind a `zValidator` / `sValidator`; no raw `c.req.json()` or `c.req.query()` in validated handlers.
12- [ ] Validator failure path returns a structured 400 from the `(result, c)` hook; no invalid data reaches business logic.
13- [ ] Expected failures throw `HTTPException`; a single root `app.onError` returns `err.getResponse()` for HTTPException and a stack-free 500 otherwise.
14- [ ] No `c.notFound()` on RPC-consumed routes (use `c.json(..., 404)`); `notFound` and `onError` registered once on the root app after middleware and routes.
15- [ ] Global error/onError response types are exposed to the client via `ApplyGlobalResponse` if the client needs to narrow on them.
16- [ ] Pinned to Hono v4 (4.12.x) on Node 22 LTS with TypeScript 5.4+; Node entry uses `@hono/node-server` `serve({ fetch: app.fetch })`.
17- [ ] RPC client uses `hc<AppType>` with `import type`; responses type-check with no `unknown` leaks (check monorepo .d.ts emission if they appear).
18- [ ] One Zod major pinned workspace-wide; `@hono/zod-validator` 0.8.x supports Zod 3 and 4.

Why this pattern

AI agents writing Hono APIs break RPC type inference, scatter validation, and return inconsistent error shapes.

Built for Backend teams building type-safe Hono APIs on Node or the edge.

Keeps your assistant from:

  • Mounting sub-apps on separate lines so the RPC client infers responses as unknown
  • Reading untrusted req.json() or query params without a schema validator
  • Returning ad-hoc error objects instead of a consistent HTTPException shape
License
Apache-2.0
Version
1.0.0
Updated
2026-06-09
View source