Spoosh
Type Adapters

Elysia

Type-safe API toolkit from your Elysia server

The @spoosh/elysia package transforms your Elysia app type into Spoosh's ApiSchema format, giving you end-to-end type safety.

For proper type inference, your Elysia routes must use TypeBox schemas for validation. The types are extracted directly from your Elysia app.

Installation

npm install @spoosh/elysia

Setup

Server (Elysia)

Define your Elysia routes and export the app type:

server.ts
import { Elysia, t } from "elysia";

const app = new Elysia({ prefix: "/api" })
  .get("/posts", () => {
    return [
      { id: 1, title: "Hello World" },
      { id: 2, title: "Getting Started" },
    ];
  })
  .post(
    "/posts",
    ({ body }) => {
      return { id: 3, title: body.title };
    },
    {
      body: t.Object({ title: t.String() }),
    }
  )
  .get("/posts/:id", ({ params }) => {
    return { id: Number(params.id), title: "Post Title" };
  })
  .delete("/posts/:id", () => {
    return { success: true };
  });

// Export the app type for client usage
export type App = typeof app;

Client (Spoosh)

Use ElysiaToSpoosh with your Elysia app type. Since the server uses a prefix (/api), use StripPrefix to avoid double prefixing:

client.ts
import { Spoosh, StripPrefix } from "@spoosh/core";
import { createAngularSpoosh } from "@spoosh/angular";
import type { ElysiaToSpoosh } from "@spoosh/elysia";
import type { App } from "./server";

// Full schema has prefixed paths: "api/posts", "api/posts/:id"
type FullSchema = ElysiaToSpoosh<App>;

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

ElysiaSpoosh
Return valueResponse data type
body: t.Object({...})Request body type
query: t.Object({...})Query params type
/posts/:id"posts/:id" (path-based key)

Path Parameters

Dynamic segments (:id, :slug, etc.) are represented as path-based keys in the schema:

// Elysia 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" },
    });
  }
}

Split Routes

ElysiaToSpoosh works seamlessly with split routes using .use():

routes/users.ts
import { Elysia, t } from "elysia";

export const usersRoutes = new Elysia({ prefix: "/users" })
  .get("/", () => [])
  .post("/", ({ body }) => body, { body: t.Object({ name: t.String() }) })
  .get("/:id", ({ params }) => ({ id: params.id }));
routes/posts.ts
import { Elysia, t } from "elysia";

export const postsRoutes = new Elysia({ prefix: "/posts" })
  .get("/", () => [])
  .post("/", ({ body }) => body, { body: t.Object({ title: t.String() }) });
app.ts
import { Elysia } from "elysia";
import { usersRoutes } from "./routes/users";
import { postsRoutes } from "./routes/posts";

const app = new Elysia({ prefix: "/api" }).use(usersRoutes).use(postsRoutes);

export type App = typeof app;
client.ts
import { Spoosh, StripPrefix } from "@spoosh/core";
import type { ElysiaToSpoosh } from "@spoosh/elysia";
import type { App } from "./app";

// Types are correctly inferred from split routes
// Strip "api" prefix since baseUrl already includes it
type ApiSchema = StripPrefix<ElysiaToSpoosh<App>, "api">;

const spoosh = new Spoosh<ApiSchema, Error>("/api");

StripPrefix

When using Elysia with a prefix like new Elysia({ prefix: "/api" }), the ElysiaToSpoosh type produces paths like api/posts, api/users. Since your baseUrl already includes the prefix, you need to use StripPrefix to avoid double prefixing (e.g., requesting /api/api/users).

import { StripPrefix } from "@spoosh/core";
import type { ElysiaToSpoosh } from "@spoosh/elysia";

// Full schema has prefixed paths: "api/posts", "api/users", "api/posts/:id"
type FullSchema = ElysiaToSpoosh<App>;

// Strip the prefix since baseUrl already includes "/api"
type ApiSchema = StripPrefix<FullSchema, "api">;
// Result: "posts", "users", "posts/:id"

StripPrefix normalizes the prefix by removing leading/trailing slashes, so "api", "/api", and "/api/" all work the same way.

On this page