Skip to content

Fix: Hono RPC Not Working — Client Type Inference, AppType Export, Validators, and Path Params

FixDevs · (Updated: )

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: true and proper module resolution, tsc can’t emit a portable type for AppType in 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:

  1. declaration: true in server/tsconfig.json:
{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true,
    "outDir": "./dist",
    "module": "ESNext",
    "moduleResolution": "Bundler"
  },
  "include": ["src/**/*"]
}
  1. "types" or "typesVersions" in server/package.json:
{
  "name": "@my-org/server",
  "types": "./dist/server.d.ts",
  "exports": {
    ".": {
      "types": "./dist/server.d.ts",
      "import": "./dist/server.js"
    }
  }
}
  1. 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 hc client and the AppType pattern. Validator integration was experimental; @hono/zod-validator lived 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 generic Response; you had to cast.
  • Hono 4.6 introduced the official OpenAPI integration (@hono/zod-openapi), which adds a parallel OpenAPIHono class. 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 used as casts 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:

  • hc type breaks after upgrading hono. Major versions change internal types. Run tsc --noEmit and grep for Hono types — sometimes you need to re-import AppType or update a peer dep.
  • client.foo.bar is any for a specific route. That route is defined outside the chain (separate app.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 the hook option: zValidator("json", schema, (result, c) => { if (!result.success) return c.json({...}, 400); }).
  • OPTIONS preflight fails. Add the cors middleware: import { cors } from "hono/cors"; app.use("*", cors()). For prod, scope origin to your domain.
  • c.env.MY_BINDING is typed as unknown on 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 use as never casts at expensive boundaries.
  • $url() doesn’t include the base URL. client.posts.$url() returns a URL with the base you passed to hc(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 tsc versions resolve node_modules/hono/dist/types differently. Set "moduleResolution": "Bundler" and pin both the toolchain and the hono version in package.json so local and CI agree.
  • client.path.$get is 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 local typeof while 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 imported zValidator from @hono/zod-validator with a version that doesn’t match your hono major. 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles