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/elysiaSetup
Server (Elysia)
Define your Elysia routes and export the app type:
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:
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
| Elysia | Spoosh |
|---|---|
| Return value | Response 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():
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 }));import { Elysia, t } from "elysia";
export const postsRoutes = new Elysia({ prefix: "/posts" })
.get("/", () => [])
.post("/", ({ body }) => body, { body: t.Object({ title: t.String() }) });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;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.