Spoosh
Integrations

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/hono

Setup

Server (Hono)

Define your Hono routes and export the app type:

server.ts
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:

client.ts
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

HonoSpoosh
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

routes/users.ts
import { Hono } from "hono";

export const usersRoutes = new Hono()
  .get("/", (c) => c.json([]))
  .post("/", (c) => c.json({}))
  .get("/:id", (c) => c.json({}));
routes/posts.ts
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

app.ts
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

client.ts
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:

routes/bookings.ts
// 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({}));
app.ts
// Mount both on the same path
const app = new Hono()
  .route("/bookings", bookingsRootRoutes)
  .route("/bookings", bookingByIdRoutes);
client.ts
// 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.

On this page