home / skills / epicenterhq / epicenter / elysia

elysia skill

/.agents/skills/elysia

This skill helps you implement robust Elysia route error handling with status(), Eden Treaty typing, and safe plugin composition for reliable APIs.

npx playbooks add skill epicenterhq/epicenter --skill elysia

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
8.2 KB
---
name: elysia
description: Elysia.js server patterns for error handling, status responses, and plugin composition. Use when writing Elysia route handlers, returning HTTP errors, creating plugins, or working with Eden Treaty type safety.
metadata:
  author: epicenter
  version: '1.0'
---

# Elysia.js Patterns (v1.2+)

## 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;
};
```

Overview

This skill documents Elysia.js server patterns for consistent error handling, status responses, and plugin composition. It focuses on using the status() helper, Eden Treaty response typing, and clear conventions for route handlers and plugins. Follow these patterns to get typesafe, self-documenting APIs and predictable control flow.

How this skill works

Destructure status from the route handler context and call status(codeOrString, message) for all non-200 responses to produce ElysiaCustomStatusResponse objects. Define response schemas per status code in route options so Eden Treaty infers error types end-to-end. Use return status(...) for normal control flow and throw status(...) only to short-circuit deep logic or catch blocks.

When to use it

  • When writing Elysia route handlers that return errors or non-200 responses
  • When you want end-to-end type safety with Eden Treaty for error responses
  • When converting legacy handlers that use set.status and object error bodies
  • When composing reusable Elysia plugins that consumers will mount with prefixes
  • When applying shared auth using guards and beforeHandle

Best practices

  • Always destructure status from handler context and prefer return status(...) for early-return guards
  • Prefer string literal statuses (e.g. 'Bad Request') for readability and IntelliSense; use numeric codes only for nonstandard cases like 499
  • Define response schemas per status code so client-side error.status and error.value are properly typed by Eden Treaty
  • Prefer plain string error bodies for simplicity; use TypeBox schemas only when structured error data is required
  • Write plugins as prefix-agnostic Elysia instances and let the consumer control mounting with prefixes

Example use cases

  • A POST /chat handler that validates provider and returns status('Bad Request', message) on invalid input
  • A streaming handler that throws status(499, 'Client closed request') inside a catch when the client aborts
  • A shared auth guard using .guard({ beforeHandle }) that returns status('Unauthorized', message) for invalid tokens
  • A reusable plugin built with new Elysia() that exposes endpoints; consumer mounts it with a prefix
  • Migrating legacy handlers: replace set.status + object returns with return status(stringLiteral, message) and add response schemas

FAQ

What is the difference between return status(...) and throw status(...)?

They produce the same response object and type inference. Prefer return status(...) for normal early returns; use throw status(...) to short-circuit deep logic or inside catch blocks.

Should error bodies be objects or strings?

Prefer plain strings for most errors. Use structured objects only when you need multiple fields and define a TypeBox schema in the route response.