Fix: Hono RPC Not Working — Client Type Inference, AppType Export, Validators, and Path Params
Part of: JavaScript & TypeScript Errors
Quick Answer
How to fix Hono RPC client errors — hc<AppType> showing any, validator types not flowing, app.route chaining loses types, monorepo type import, path param typing, JSON body validation, and streaming.
The Error
You wire up Hono’s RPC client and the response is typed as any:
import { hc } from "hono/client";
import type { AppType } from "./server";
const client = hc<AppType>("http://localhost:8787");
const res = await client.posts.$get();
const data = await res.json();
// data: any — should be Post[]Or the validator types don’t reach the client:
// server.ts
app.post("/posts", zValidator("json", postSchema), (c) => {
const body = c.req.valid("json"); // typed
return c.json({ id: 1, ...body });
});
// client.ts
await client.posts.$post({
json: { title: "Hi" }, // No type check on shape.
});Or app.route composition causes types to widen:
const posts = new Hono().get("/", ...).post("/", ...);
const users = new Hono().get("/", ...);
const app = new Hono().route("/posts", posts).route("/users", users);
export type AppType = typeof app;
// Client of AppType has no methods on .posts.Or in a monorepo, importing AppType works at type-check time but the build fails:
error TS2742: The inferred type of 'AppType' cannot be named without a reference
to '../../node_modules/hono/dist/types'.Why This Happens
Hono RPC works by exporting the app’s full type signature and consuming it on the client. The signature is inferred — built up by every app.get, app.post, app.use chain. Three things make it fragile:
- Type inference depends on method chaining. Each
.get(...)returns a new type that extends the previous. If you split the chain across statements or files, TypeScript loses the inferred type and you get the widened base. - Validators only flow types via the chained API.
zValidator("json", schema)produces a middleware whose type signature includes the validated shape. The chain must keep that type alive. - Inferred types reference Hono’s internals. Without
declaration: trueand proper module resolution,tsccan’t emit a portable type forAppTypein a monorepo.
The RPC client is built around TypeScript’s structural inference rather than codegen. Where tRPC requires you to define a router object and feed it through a createTRPCProxyClient, Hono treats every chained call as a unit of type information. That means a missing chain link doesn’t trigger a build error — it just degrades the client type to any, which is the exact failure mode that ships to production unnoticed. Lint rules cannot catch this because the route is still valid runtime code; the lost type is what causes downstream bugs.
A second reason this fails silently is the speed of the Hono client’s type traversal. hc<AppType> recursively walks the schema each time you access a property — client.posts[":id"] is not pre-baked; it is computed on the fly by the TypeScript checker. When AppType grows very large or includes deeply nested validators, this traversal hits TypeScript’s instantiation depth limit and silently widens to any for parts of the tree. The result looks like a partial outage of the type system: some routes have full typing, others none.
A third common cause is dependency drift in monorepos. The AppType your client consumes is bound to the exact version of hono installed in the server package. If the client resolves a different hono version (a stricter peerDependencies failure or a workspace:* mismatch), the Hono, Context, and validator types diverge, and the inference no longer flows cleanly across the boundary. Pinning hono in both packages and ensuring a single instance via resolutions (or pnpm’s overrides) avoids this entirely.
Fix 1: Export AppType From a Single Chained Expression
The simplest working pattern — define and export in one chained expression:
// server.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
const postSchema = z.object({
title: z.string(),
body: z.string(),
});
const app = new Hono()
.get("/posts", (c) => c.json([{ id: 1, title: "Hi", body: "..." }]))
.post("/posts", zValidator("json", postSchema), (c) => {
const data = c.req.valid("json");
return c.json({ id: 2, ...data });
})
.get("/posts/:id", (c) => {
const id = c.req.param("id");
return c.json({ id, title: "Hi", body: "..." });
});
export type AppType = typeof app;
export default app;// client.ts
import { hc } from "hono/client";
import type { AppType } from "./server";
const client = hc<AppType>("http://localhost:8787");
const res = await client.posts.$get();
const posts = await res.json();
// posts: { id: number; title: string; body: string }[]
await client.posts.$post({
json: { title: "New", body: "..." }, // Type-checked.
});
await client.posts[":id"].$get({ param: { id: "1" } });hc<AppType> walks the type and produces a client where each path is a property and each HTTP method is a function. $get, $post, etc. correspond to the methods you defined.
Pro Tip: Keep the entire app as one chained new Hono().get(...).post(...). The moment you do const app = new Hono(); app.get(...); (separate statements), inference loses the route info.
Fix 2: Compose Subrouters With .route() While Preserving Types
For larger apps, split routes into modules but compose them with chained .route() calls:
// routes/posts.ts
import { Hono } from "hono";
export const posts = new Hono()
.get("/", (c) => c.json([] as Post[]))
.post("/", (c) => c.json({} as Post));
// routes/users.ts
import { Hono } from "hono";
export const users = new Hono()
.get("/", (c) => c.json([] as User[]));
// server.ts
import { Hono } from "hono";
import { posts } from "./routes/posts";
import { users } from "./routes/users";
const app = new Hono()
.route("/posts", posts)
.route("/users", users);
export type AppType = typeof app;
export default app;Now client.posts.$get() and client.users.$get() both work, with the full type info from each subrouter.
Common Mistake: Calling .route() on multiple lines:
const app = new Hono();
app.route("/posts", posts); // type widens
app.route("/users", users); // type widens further
export type AppType = typeof app; // Almost nothing typed.The chained form is what carries the types forward. Stick to method chaining for the top-level app.
Fix 3: Wire Validators So Types Flow
@hono/zod-validator (or @hono/valibot-validator, @hono/typebox-validator) produces a middleware that carries the validated schema’s type. To see it on the client, declare it in the chain:
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
const querySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
const app = new Hono()
.get("/posts", zValidator("query", querySchema), (c) => {
const { page, limit } = c.req.valid("query");
return c.json({ page, limit, items: [] as Post[] });
});On the client:
await client.posts.$get({
query: { page: "2", limit: "10" }, // Type-checked against querySchema.
});Validator targets are json (body), form (form data), query, param, header, and cookie. The client args match: { json, form, query, param, header, cookie }.
Note: For query and param, the client values are always strings (URL serialization). Use z.coerce.number() (or equivalent) in the schema to convert before validation.
Fix 4: Path Parameters
Use the colon syntax in routes and access via c.req.param:
const app = new Hono()
.get("/posts/:id", (c) => {
const id = c.req.param("id"); // string
return c.json({ id, title: "Hi" });
})
.get("/users/:userId/posts/:postId", (c) => {
const { userId, postId } = c.req.param();
return c.json({ userId, postId });
});On the client, params are typed in a nested key matching the route segments:
await client.posts[":id"].$get({ param: { id: "1" } });
await client.users[":userId"].posts[":postId"].$get({
param: { userId: "1", postId: "5" },
});The bracket notation on the client matches the literal :id segment — that’s how Hono RPC encodes path params.
Pro Tip: Validate params with zValidator("param", ...) if you need stricter typing or runtime checks:
.get(
"/posts/:id",
zValidator("param", z.object({ id: z.coerce.number().int() })),
(c) => {
const { id } = c.req.valid("param"); // number, not string
return c.json({ id });
},
);Fix 5: Monorepo Type Import
In a monorepo where client/ and server/ are separate packages, importing AppType cross-package needs:
declaration: trueinserver/tsconfig.json:
{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"module": "ESNext",
"moduleResolution": "Bundler"
},
"include": ["src/**/*"]
}"types"or"typesVersions"inserver/package.json:
{
"name": "@my-org/server",
"types": "./dist/server.d.ts",
"exports": {
".": {
"types": "./dist/server.d.ts",
"import": "./dist/server.js"
}
}
}- Import type-only in client:
import type { AppType } from "@my-org/server";
import { hc } from "hono/client";
const client = hc<AppType>("...");Using import type (not import) keeps the runtime bundle free of server code. The type-only import erases at build time.
Common Mistake: Forgetting to build the server before consuming its types. Run tsc --build on the server first (or use a watch mode), or set up a turborepo task that orders the builds.
Fix 6: JSON Responses and Streaming
By default c.json(...) returns a typed JSON response. For streaming:
import { stream, streamText } from "hono/streaming";
app.get("/stream", (c) => {
return stream(c, async (stream) => {
for (let i = 0; i < 5; i++) {
await stream.write(`chunk ${i}\n`);
await stream.sleep(100);
}
});
});The client gets a Response object — call .body for the ReadableStream:
const res = await client.stream.$get();
const reader = res.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log(decoder.decode(value));
}For SSE specifically, use streamSSE:
import { streamSSE } from "hono/streaming";
app.get("/events", (c) => {
return streamSSE(c, async (stream) => {
let id = 0;
while (true) {
await stream.writeSSE({ data: `tick ${id}`, event: "message", id: String(id++) });
await stream.sleep(1000);
}
});
});Note: RPC client types don’t capture stream body shape — the response is Response. Wrap the streaming client call in a typed helper if you need a specific contract.
Fix 7: Cookies, Headers, and Sessions
Read cookies and headers on the server:
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
app.post("/login", async (c) => {
setCookie(c, "session", "abc123", {
httpOnly: true,
secure: true,
sameSite: "Lax",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
return c.json({ ok: true });
});
app.get("/me", (c) => {
const session = getCookie(c, "session");
if (!session) return c.json({ error: "unauthenticated" }, 401);
return c.json({ user: { id: 1 } });
});On the client, cookies are handled by fetch automatically (credentials: "include"):
const client = hc<AppType>("https://api.example.com", {
init: { credentials: "include" },
});Without credentials: "include", cross-origin cookies are dropped silently.
Common Mistake: Setting SameSite: "Strict" cookies and then expecting them on cross-origin clients. Use "Lax" or "None" (with Secure: true) for cross-origin.
Fix 8: Handling RPC Client Errors
client.posts.$get() returns a Response. Errors don’t throw — you must check res.ok:
const res = await client.posts.$get();
if (!res.ok) {
const err = await res.json();
throw new Error(`HTTP ${res.status}: ${err.message ?? "unknown"}`);
}
const data = await res.json();For type-safe error handling, return a discriminated union from the server:
app.get("/posts/:id", (c) => {
const id = c.req.param("id");
const post = findPost(id);
if (!post) return c.json({ error: "not_found" as const }, 404);
return c.json(post);
});On the client:
const res = await client.posts[":id"].$get({ param: { id: "1" } });
const data = await res.json();
// data: Post | { error: "not_found" }
if ("error" in data) {
// handle
}as const makes the error tag a literal type, so the client can narrow with in checks.
Version History and Tooling Context
Hono RPC has changed shape several times since it shipped. Knowing which version introduced what saves hours of “why doesn’t this docs example work” debugging:
- Hono 3.0 (May 2023) introduced the
hcclient and theAppTypepattern. Validator integration was experimental;@hono/zod-validatorlived in a separate package and its type flow was less reliable than today. - Hono 4.0 (January 2024) stabilized RPC. The chained-inference behavior described here became canonical, and the JSR-distributed packages became the preferred install method for Deno. Many “RPC works on my machine” issues on Bun came from running 3.x docs against 4.x runtime.
- Hono 4.4 improved the streaming response types and added typed SSE helpers via
streamSSE. Before 4.4,client.events.$get()typed the body as a genericResponse; you had to cast. - Hono 4.6 introduced the official OpenAPI integration (
@hono/zod-openapi), which adds a parallelOpenAPIHonoclass. RPC clients work the same way, but the inferred types now include the OpenAPI documentation metadata, which can blow up the depth limit on large schemas. - Hono 4.7+ added Cookie partition support and tightened the
c.req.valid()return types for nested validators. Older code that usedascasts to satisfy validator types often started failing on 4.7 because the inference improved past the cast.
Compared to other typed RPC stacks: tRPC uses an explicit router object and procedure builder, giving more predictable error messages but more boilerplate. ts-rest defines contracts separately from handlers, making it natural to share types between an existing Express server and a new client. Hono RPC is the fastest path from “I’m building a Hono server anyway” to “my client is fully typed,” but it pays a price in fragility: the chain must stay intact. If your team values explicit contracts, ts-rest or tRPC is the safer pick; if you value zero ceremony and you control both ends, Hono RPC wins on developer velocity.
Still Not Working?
A few less-obvious failures:
hctype breaks after upgradinghono. Major versions change internal types. Runtsc --noEmitand grep forHonotypes — sometimes you need to re-importAppTypeor update a peer dep.client.foo.barisanyfor a specific route. That route is defined outside the chain (separateapp.get(...)statement). Move it into the main chain.- Validator errors come back as
{ error: ... }shape you didn’t define. That’s the default zod-validator error response. Customize with thehookoption:zValidator("json", schema, (result, c) => { if (!result.success) return c.json({...}, 400); }). OPTIONSpreflight fails. Add the cors middleware:import { cors } from "hono/cors"; app.use("*", cors()). For prod, scopeoriginto your domain.c.env.MY_BINDINGis typed asunknownon Cloudflare. Pass the Env generic:new Hono<{ Bindings: { MY_BINDING: KVNamespace } }>().- Build fails with
Excessive stack depth comparing types. Your AppType is too complex. Split into multiple subrouters and compose, or useas nevercasts at expensive boundaries. $url()doesn’t include the base URL.client.posts.$url()returns a URL with the base you passed tohc(baseUrl). If empty, you passed""— pass the real origin.- WebSocket support in RPC client. WebSocket routes aren’t part of the RPC client type. Use Hono’s native WS upgrade and a separate typed client wrapper.
- Type-check passes locally but breaks in CI with TS2742. Different Node or
tscversions resolvenode_modules/hono/dist/typesdifferently. Set"moduleResolution": "Bundler"and pin both the toolchain and thehonoversion inpackage.jsonso local and CI agree. client.path.$getis typed but the runtime call returns 404. The route lives in a subrouter, but you forgot to chain it with.route("/prefix", sub)on the main app — types come from the localtypeofwhile the running server has no such mount. Confirm the prefix matches what the client expects.- Validator schema works in isolation but the inferred client args are
{}. You importedzValidatorfrom@hono/zod-validatorwith a version that doesn’t match yourhonomajor. Update both packages together; the validator types are version-locked to the host runtime types.
For related TypeScript RPC, edge runtime, and tooling issues, see tRPC not working, Hono not working, Cloudflare D1 not working, and TanStack Query not working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Bun Bundler Not Working — Targets, Format, Externals, Macros, and Code Splitting
How to fix bun build errors — target (browser/bun/node) mismatch, format esm/cjs/iife, externals not respected, Bun macros at compile time, splitting and chunks, plugin API, and Bun.build vs CLI.
Fix: Bun Shell Not Working — $ Template Quoting, Pipes, Exit Codes, and Cross-Platform Scripts
How to fix Bun Shell errors — $ template auto-escape vs raw strings, piping with pipe() vs |, throws on non-zero exit, cwd/env scoping, glob expansion differences, and Windows path handling.
Fix: Bun Test Not Working — Module Mocking, DOM Setup, Coverage, and Watch Mode
How to fix Bun test runner issues — mock.module not isolating, happy-dom setup for DOM tests, --coverage missing files, timer mocks, snapshot updates, TypeScript path aliases, and preload files.
Fix: Scalar Not Working — API Docs Not Rendering, Try-It Not Sending Requests, or Theme Broken
How to fix Scalar API documentation issues — OpenAPI spec loading, interactive Try-It panel, authentication configuration, custom themes, CDN and React integration, and self-hosting.