elysia
$
npx mdskill add EpicenterHQ/epicenter/elysiaHandles Elysia.js patterns for API route handlers and error responses
- Solves tasks like error handling, status responses, and plugin composition
- Uses Elysia.js, Eden Treaty, and Cloudflare Workers APIs
- Chooses between status returns and exceptions based on control flow
- Returns structured JSON responses with type-safe client compatibility
SKILL.md
.github/skills/elysiaView on GitHub ↗
---
name: elysia
description: 'Elysia.js: error handling, status responses, plugin composition. Use for Elysia, Eden Treaty, API route handlers, HTTP errors, type-safe clients.'
metadata:
author: epicenter
version: '1.0'
---
# Elysia.js Patterns (v1.2+)
## Reference Repositories
- [Hono](https://github.com/honojs/hono) : Ultrafast web framework for Cloudflare Workers
- [Cloudflare Docs](https://github.com/cloudflare/cloudflare-docs) : Cloudflare Workers, Durable Objects, KV documentation
## Upstream Grounding
When Worker runtime behavior, Hono middleware semantics, request/response streaming, Durable Objects, WebSockets, or Cloudflare deployment limits affect correctness, ask DeepWiki a narrow question against `honojs/hono` or `cloudflare/cloudflare-docs` before relying on memory. Use it to orient, then verify decisive details against local installed types, source, or official docs before changing code.
Skip DeepWiki for Elysia-specific conventions already documented below.
## When to Apply This Skill
Use this pattern when you need to:
- Write or refactor Elysia handlers to use `status()` responses.
- Define per-status response schemas for Eden Treaty type safety.
- Migrate handlers away from `set.status` plus error-object returns.
- Compose Elysia plugins/guards for shared auth and route behavior.
- Choose between `return status(...)` and `throw status(...)` by control-flow context.
## The `status()` Helper (ALWAYS use this)
**Never use `set.status` + return object.** Always destructure `status` from the handler context and use it for all non-200 responses. This gives you:
- Typesafe string literals with full IntelliSense (e.g. `"Bad Request"` instead of `400`)
- Automatic response type inference per status code
- Eden Treaty end-to-end type safety on error responses
### Basic Usage
```typescript
import { Elysia, t } from 'elysia';
new Elysia().post(
'/chat',
async ({ body, headers, status }) => {
// ^^^^^^ destructure status from context
if (!isValid(body.provider)) {
// Use string literal for self-documenting, typesafe status codes
return status('Bad Request', 'Unsupported provider');
}
if (!apiKey) {
return status('Unauthorized', 'Missing API key');
}
return doWork(body);
},
{
// Define response schemas per status code for full type safety
response: {
200: t.Any(),
400: t.String(),
401: t.String(),
},
},
);
```
### `return status()` vs `throw status()`
Both work. The framework handles either. The difference is purely control flow:
| Pattern | Behavior | Use when |
| -------------------- | --------------------------------------------- | ---------------------------------------------------------------------- |
| `return status(...)` | Normal return, continues to response pipeline | You're at a natural return point (validation guards, end of handler) |
| `throw status(...)` | Short-circuits execution immediately | You're deep in nested logic or inside a try/catch and want to bail out |
**This codebase convention: prefer `return status(...)`.** It matches the existing early-return-on-error pattern used everywhere else (see `error-handling` skill). Reserve `throw status(...)` for catch blocks or deeply nested code where `return` would be awkward.
```typescript
// GOOD: return for validation guards (matches codebase style)
async ({ body, status }) => {
if (!isValid(body.provider)) {
return status('Bad Request', `Unsupported provider: ${body.provider}`);
}
const apiKey = resolveApiKey(body.provider, headerApiKey);
if (!apiKey) {
return status('Unauthorized', 'Missing API key');
}
// happy path
return doWork(body);
};
// GOOD: throw inside catch blocks
async ({ body, status }) => {
try {
return await streamResponse(body);
} catch (error) {
if (isAbortError(error)) {
throw status(499, 'Client closed request');
}
throw status('Bad Gateway', `Provider error: ${error.message}`);
}
};
```
### Type inference is identical for both
Both `return status(...)` and `throw status(...)` produce the same `ElysiaCustomStatusResponse` object. Elysia's type system infers response types from the `response` schema in route options, not from how you invoke `status()`. Eden Treaty type safety works equally with either approach.
## Available String Status Codes (StatusMap)
Use these string literals instead of numeric codes for better readability:
| String Literal | Code | Common Use |
| ------------------------- | ---- | ------------------------------------------ |
| `'Bad Request'` | 400 | Validation failures, malformed input |
| `'Unauthorized'` | 401 | Missing/invalid auth credentials |
| `'Forbidden'` | 403 | Valid auth but insufficient permissions |
| `'Not Found'` | 404 | Resource doesn't exist |
| `'Conflict'` | 409 | State conflict (duplicate, already exists) |
| `'Unprocessable Content'` | 422 | Semantically invalid input |
| `'Too Many Requests'` | 429 | Rate limiting |
| `'Internal Server Error'` | 500 | Unexpected server failure |
| `'Bad Gateway'` | 502 | Upstream provider error |
| `'Service Unavailable'` | 503 | Temporary overload/maintenance |
For non-standard codes (e.g. nginx's 499), use the numeric literal directly: `status(499, 'Client closed request')`.
## Response Schemas for Eden Treaty Type Safety
Define `response` schemas per status code in route options. This is what makes Eden Treaty infer error types on the client:
```typescript
new Elysia().post(
'/chat',
async ({ body, status }) => {
if (!isValid(body.provider)) {
return status('Bad Request', `Unsupported provider: ${body.provider}`);
}
return streamResult;
},
{
body: t.Object({
provider: t.String(),
model: t.String(),
}),
response: {
200: t.Any(), // Success type
400: t.String(), // Bad Request body type
401: t.String(), // Unauthorized body type
502: t.String(), // Bad Gateway body type
},
},
);
```
Eden Treaty then infers:
```typescript
const { data, error } = await api.chat.post({
provider: 'openai',
model: 'gpt-4',
});
if (error) {
// error.status is typed as 400 | 401 | 502
// error.value is typed per status code (string in this case)
switch (error.status) {
case 400: // error.value: string
case 401: // error.value: string
case 502: // error.value: string
}
}
```
## Error Response Body: Strings vs Objects
**Prefer plain strings as error bodies.** The status code already communicates the error class. A descriptive string message is sufficient and keeps the API simple.
```typescript
// GOOD: Plain string - status code provides the category
return status('Bad Request', `Unsupported provider: ${provider}`);
return status('Unauthorized', 'Missing API key: set x-provider-api-key header');
// AVOID: Wrapping in { error: "..." } object - redundant with status code
set.status = 400;
return { error: `Unsupported provider: ${provider}` };
```
If you need structured error bodies (multiple fields, error codes, validation details), define a TypeBox schema:
```typescript
const ErrorBody = t.Object({
message: t.String(),
code: t.Optional(t.String()),
});
// In route options:
response: {
400: ErrorBody,
401: ErrorBody,
}
```
## Plugin Composition
Elysia plugins are just functions that return Elysia instances. Use `new Elysia()` inside the plugin, not `new Elysia({ prefix })` : let the consumer control mounting:
```typescript
// GOOD: Plugin is prefix-agnostic
export function createMyPlugin() {
return new Elysia().post('/endpoint', async ({ body, status }) => {
// ...
});
}
// Consumer controls the prefix
app.use(new Elysia({ prefix: '/api' }).use(createMyPlugin()));
```
## Guards for Shared Auth
Use `.guard()` with `beforeHandle` for auth that applies to multiple routes:
```typescript
const authed = new Elysia().guard({
async beforeHandle({ headers, status }) {
const token = extractBearerToken(headers.authorization);
if (!isValid(token)) {
return status('Unauthorized', 'Invalid or missing token');
}
},
});
// All routes under this guard require auth
return authed
.get('/protected', () => 'secret')
.post('/admin', () => 'admin stuff');
```
## Migration Checklist: `set.status` to `status()`
When updating existing handlers:
1. Replace `set` with `status` in the handler destructuring
2. Replace `set.status = N; return { error: msg };` with `return status('String Literal', msg);`
3. In catch blocks, use `throw status(...)` instead of `set.status = N; return { error: msg };`
4. Add `response` schemas to route options for Eden Treaty type inference
5. Keep `set` in the destructuring ONLY if you still need `set.headers` for things like `content-type`
```typescript
// BEFORE
async ({ body, headers, set }) => {
if (!valid) {
set.status = 400;
return { error: 'Bad input' };
}
};
// AFTER
async ({ body, headers, status }) => {
if (!valid) {
return status('Bad Request', 'Bad input');
}
};
// AFTER (when you also need set.headers)
async ({ body, headers, set, status }) => {
if (!valid) {
return status('Bad Request', 'Bad input');
}
set.headers['content-type'] = 'application/octet-stream';
return binaryData;
};
```
More from EpicenterHQ/epicenter
- agent-goalWrite `/goal` prompts for long-running agent work in Codex or Claude Code. Use for slash goal, agent goal, durable objective, autonomous coding run.
- approachability-auditReview code as a new TypeScript developer. Use when code feels indirect, clever, hard to follow, or needs a pass on abstractions, names, first-read clarity.
- arktypeArktype: runtime validation, discriminated unions with .merge()/.or(), spread keys. Use when mentioning arktype, type(), union types, command/event schemas.
- attach-primitiveContract and invariants for `attach*` composition primitives in `packages/workspace` (side-effectful building blocks like attachIndexedDb, attachSqlite, attachBroadcastChannel, attachEncryption, attachTable, openCollaboration), and when to use `create*` (pure construction) instead. Use when writing or reviewing an `attach*` or `create*` function, naming a new workspace primitive, composing inside a workspace builder, or deciding whether a primitive registers listeners at call time.
- authEpicenter auth packages: `@epicenter/auth`, `@epicenter/auth-svelte`, OAuth sessions, identity state, auth-owned fetch/WebSocket, and workspace lifecycle binding. Use when editing Epicenter auth clients, session state, hosted sign-in, or auth/workspace integration.
- autumnAutumn billing in Epicenter: `autumn.config.ts`, `autumn-js` credit checks, `atmn` CLI, plan gates, and metered AI usage. Use when changing billing, pricing, credits, plan access, refunds, or usage events.
- better-auth-best-practicesBetter Auth server/client setup: `auth.ts`, generated schema, DB adapters, sessions, cookies, env vars, and plugins. Use when mentioning Better Auth, betterauth, auth handlers, OAuth, email/password, or session configuration.
- better-auth-security-best-practicesBetter Auth security hardening: rate limits, secrets, CSRF, trusted origins, cookies, sessions, OAuth tokens, and audit logging. Use when reviewing auth security, brute-force protection, token handling, or deployment safety.
- change-proposalPresent proposed code changes visually before implementing. Use when: "show me options", "compare approaches", "what should we do", or when changes need before/after comparison.
- claude-code-consultUse this skill when the user asks to consult Claude, ask Claude Code, get another model's take, run a taste check, find cleaner options, or prepare a Claude prompt. Create a bounded second-opinion prompt or run a read-only Claude Code consult, then verify Claude's claims against local files.