Skip to content

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

FixDevs ·

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.

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.

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.

For related TypeScript RPC and edge runtime issues, see tRPC not working, Hono not working, Cloudflare D1 not working, and TypeScript cannot find module.

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