Hono
Type-safe API client from your Hono server
The @spoosh/hono package transforms your Hono app type into Spoosh's ApiSchema format, giving you end-to-end type safety.
For proper type inference, your Hono routes must follow the Hono RPC guide. Chain your routes directly on the app instance and export the app type.
Installation
npm install @spoosh/honoSetup
Server (Hono)
Define your Hono routes and export the app type:
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
const app = new Hono()
.basePath("/api")
.get("/posts", (c) => {
return c.json([
{ id: 1, title: "Hello World" },
{ id: 2, title: "Getting Started" },
]);
})
.post("/posts", zValidator("json", z.object({ title: z.string() })), (c) => {
const body = c.req.valid("json");
return c.json({ id: 3, title: body.title });
})
.get("/posts/:id", (c) => {
const id = c.req.param("id");
return c.json({ id: Number(id), title: "Post Title" });
})
.delete("/posts/:id", (c) => {
return c.json({ success: true });
});
// Export the app type for client usage
export type AppType = typeof app;Client (Spoosh)
Use HonoToSpoosh with your Hono app type:
import { Spoosh, StripPrefix } from "@spoosh/core";
import { createAngularSpoosh } from "@spoosh/angular";
import type { HonoToSpoosh } from "@spoosh/hono";
import type { AppType } from "./server";
// Full schema has prefixed paths: "api/posts", "api/posts/:id"
type FullSchema = HonoToSpoosh<AppType>;
// Since baseUrl already includes "/api", strip the prefix to avoid
// double prefixing (e.g., "api/api/posts")
type ApiSchema = StripPrefix<FullSchema, "api">;
const spoosh = new Spoosh<ApiSchema, Error>("http://localhost:3000/api");
export const { injectRead, injectWrite } = createAngularSpoosh(spoosh);Usage
All API calls are fully typed:
// GET /api/posts
const { data: posts } = await api("posts").GET();
// posts: { id: number; title: string }[]
// POST /api/posts
const { data: newPost } = await api("posts").POST({
body: { title: "New Post" }, // body is typed
});
// newPost: { id: number; title: string }
// GET /api/posts/1
const { data: post } = await api("posts/:id").GET({ params: { id: 1 } });
// post: { id: number; title: string }
// DELETE /api/posts/1
await api("posts/:id").DELETE({ params: { id: 1 } });Type Mapping
| Hono | Spoosh |
|---|---|
c.json(data) | Response data type |
zValidator("json", schema) | Request body type |
zValidator("query", schema) | Query params type |
zValidator("form", schema) | Form data type |
/posts/:id | "posts/:id" (path-based key) |
Path Parameters
Dynamic segments (:id, :slug, etc.) are represented as path-based keys in the schema:
// Hono route: /users/:userId/posts/:postId
// Path-based access with params object
api("users/:userId/posts/:postId").GET({
params: { userId: 123, postId: 456 },
});
// With variables
const userId = 123;
const postId = 456;
api("users/:userId/posts/:postId").GET({
params: { userId, postId },
});With Angular
import { Component, inject, input } from "@angular/core";
import { injectRead, injectWrite } from "./api/client";
@Component({
selector: "app-user-profile",
template: `
<div>
<h1>{{ user()?.name }}</h1>
<button (click)="handleUpdate()">Update</button>
</div>
`,
})
export class UserProfileComponent {
userId = input.required<number>();
user = injectRead((api) =>
api("users/:id").GET({ params: { id: this.userId() } })
);
updateUser = injectWrite((api) => api("users/:id").PUT);
deleteUser = injectWrite((api) => api("users/:id").DELETE);
async handleUpdate() {
await this.updateUser.trigger({
params: { id: this.userId() },
body: { name: "Updated" },
});
}
}Handling Large Apps (TS2589)
When your Hono app has many routes (20+), you may encounter TypeScript error TS2589:
Type instantiation is excessively deep and possibly infinite.This is a known TypeScript limitation with deeply nested type transformations. The solution is to split your routes and transform them separately.
Solution: Split-App Pattern
Instead of using HonoToSpoosh with your entire app type, split your routes into separate groups and transform them separately.
Step 1: Organize routes into separate files
import { Hono } from "hono";
export const usersRoutes = new Hono()
.get("/", (c) => c.json([]))
.post("/", (c) => c.json({}))
.get("/:id", (c) => c.json({}));import { Hono } from "hono";
export const postsRoutes = new Hono()
.get("/", (c) => c.json([]))
.post("/", (c) => c.json({}));Step 2: Mount routes in your main app
import { Hono } from "hono";
import { usersRoutes } from "./routes/users";
import { postsRoutes } from "./routes/posts";
const app = new Hono()
.basePath("/api")
.route("/users", usersRoutes)
.route("/posts", postsRoutes);
export default app;Step 3: Define schema using HonoToSpoosh
import { Spoosh } from "@spoosh/core";
import { createAngularSpoosh } from "@spoosh/angular";
import type { HonoToSpoosh } from "@spoosh/hono";
import type { usersRoutes } from "./routes/users";
import type { postsRoutes } from "./routes/posts";
// Pre-compute each route type separately (helps TypeScript caching)
type UsersSchema = HonoToSpoosh<typeof usersRoutes>;
type PostsSchema = HonoToSpoosh<typeof postsRoutes>;
type ApiSchema = {
users: UsersSchema;
posts: PostsSchema;
};
const spoosh = new Spoosh<ApiSchema, Error>("/api");
export const { injectRead, injectWrite } = createAngularSpoosh(spoosh);Splitting Complex Route Groups
If a single route group is still causing TS2589, split it further by route pattern:
// Root-level routes
export const bookingsRootRoutes = new Hono()
.get("/", (c) => c.json([]))
.post("/", (c) => c.json({}))
.post("/summary", (c) => c.json({}));
// Routes with :id parameter
export const bookingByIdRoutes = new Hono()
.get("/:id", (c) => c.json({}))
.patch("/:id", (c) => c.json({}))
.post("/:id/confirm", (c) => c.json({}));// Mount both on the same path
const app = new Hono()
.route("/bookings", bookingsRootRoutes)
.route("/bookings", bookingByIdRoutes);// Merge the types with intersection
type BookingsRoot = HonoToSpoosh<typeof bookingsRootRoutes>;
type BookingById = HonoToSpoosh<typeof bookingByIdRoutes>;
type ApiSchema = {
bookings: BookingsRoot & BookingById;
};Pre-computing types as separate type aliases (like BookingsRoot and
BookingById) helps TypeScript's type caching, which can prevent the deep
instantiation issue even when combining them with intersection.
Last Resort: @ts-expect-error
In rare cases, even after splitting routes, certain endpoints may still trigger TS2589. When this happens, you can use @ts-expect-error as a targeted workaround:
// @ts-expect-error TS2589 - complex endpoint type
const updateBooking = injectWrite((api) => api("bookings/:id/confirm").POST);Only use @ts-expect-error for specific problematic endpoints, not as a
blanket solution. The type safety still works at runtime—this just suppresses
the compile-time error for that particular usage.