Fix: Hono RPC Not Working — Client Type Inference, AppType Export, Validators, and Path Params
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.
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.
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.
For related TypeScript RPC and edge runtime issues, see tRPC not working, Hono not working, Cloudflare D1 not working, and TypeScript cannot find module.
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.