restrust
v0.1.0
Published
Typed HTTP errors and standardized API response builders. Framework-agnostic, zero dependencies.
Maintainers
Readme
The Problem
Every backend project reinvents the same things:
| Without restrust | With restrust |
|---|---|
| res.status(404).json({ error: "not found" }) | throw new NotFoundError('User not found') |
| Inconsistent response shapes across endpoints | Every response follows { success, data/error } |
| if (err.statusCode) ... type-guessing in middleware | HttpError.isHttpError(err) type guard |
| Manual pagination math in every list endpoint | paginated(items, { page, perPage, total }) |
| Validation errors as unstructured strings | invalid([{ field: 'email', message: '...' }]) |
Installation
npm install restrustZero dependencies. TypeScript-first. Framework-agnostic.
Quick Start
import {
ok, fail, paginated, invalid, guard, useApiResponse,
NotFoundError, ValidationError,
} from 'restrust';
// ✅ Success
res.json(ok({ id: 1, name: 'Alice' }));
// → { success: true, data: { id: 1, name: 'Alice' } }
// ❌ Error
throw new NotFoundError('User not found', { code: 'USER_NOT_FOUND' });
// Caught by guard() → { success: false, error: { status: 404, message: '...', code: '...' } }
// 📄 Paginated
res.json(paginated(users, { page: 1, perPage: 20, total: 100 }));
// → { success: true, data: [...], pagination: { page, perPage, total, totalPages, hasNext, hasPrev } }
// 🚫 Validation
res.status(422).json(invalid([
{ field: 'email', message: 'Invalid format' },
{ field: 'age', message: 'Must be >= 18' },
]));HTTP Errors
Every error is a proper Error subclass with a status, optional code, details, and errors array.
Error Classes
| Class | Status | Default Message |
|-------|--------|----------------|
| BadRequestError | 400 | Bad Request |
| UnauthorizedError | 401 | Unauthorized |
| ForbiddenError | 403 | Forbidden |
| NotFoundError | 404 | Not Found |
| MethodNotAllowedError | 405 | Method Not Allowed |
| ConflictError | 409 | Conflict |
| GoneError | 410 | Gone |
| ValidationError | 422 | Validation Failed |
| TooManyRequestsError | 429 | Too Many Requests |
| InternalServerError | 500 | Internal Server Error |
| BadGatewayError | 502 | Bad Gateway |
| ServiceUnavailableError | 503 | Service Unavailable |
| GatewayTimeoutError | 504 | Gateway Timeout |
Usage
import { NotFoundError, ConflictError, ValidationError } from 'restrust';
// Simple
throw new NotFoundError('User not found');
// With machine-readable code
throw new ConflictError('Email already exists', { code: 'DUPLICATE_EMAIL' });
// With details
throw new NotFoundError('Order not found', {
code: 'ORDER_NOT_FOUND',
details: { orderId: 'abc-123' },
});
// With cause chain
try {
await db.query('...');
} catch (err) {
throw new InternalServerError('Database query failed', { cause: err });
}Validation Errors
throw new ValidationError([
{ field: 'email', message: 'Invalid format', code: 'INVALID_EMAIL' },
{ field: 'password', message: 'Must be at least 8 characters' },
]);
// → 422 with errors array in JSONRate Limiting
throw new TooManyRequestsError('Slow down', { retryAfter: 60 });
// → 429 with retryAfter in JSON responseBase HttpError
Create any status code error:
import { HttpError } from 'restrust';
throw new HttpError(418, "I'm a teapot", { code: 'TEAPOT' });Type Guard
import { HttpError } from 'restrust';
if (HttpError.isHttpError(err)) {
res.status(err.status).json(err.toJSON());
}Response Builders
ok(data, meta?)
res.json(ok({ id: 1, name: 'Alice' }));
// → { success: true, data: { id: 1, name: 'Alice' } }
res.json(ok(user, { cached: true }));
// → { success: true, data: {...}, meta: { cached: true } }created(data, meta?)
res.status(201).json(created({ id: 42, name: 'New item' }));
// → { success: true, data: { id: 42, name: 'New item' } }fail(error) / fail(status, message, code?, details?)
// From HttpError
res.status(404).json(fail(new NotFoundError('Not found')));
// From raw values
res.status(400).json(fail(400, 'Invalid input', 'BAD_INPUT'));paginated(data, { page, perPage, total }, meta?)
const users = await db.users.findMany({ skip: 0, take: 20 });
const total = await db.users.count();
res.json(paginated(users, { page: 1, perPage: 20, total }));Output:
{
"success": true,
"data": [{ "id": 1, "name": "Alice" }, "..."],
"pagination": {
"page": 1,
"perPage": 20,
"total": 100,
"totalPages": 5,
"hasNext": true,
"hasPrev": false
}
}invalid(errors, message?)
res.status(422).json(invalid([
{ field: 'email', message: 'Required' },
{ field: 'name', message: 'Too short', code: 'MIN_LENGTH' },
]));Error Handler: guard()
Wraps async route handlers. Catches errors and sends structured JSON responses.
import { guard, ok, NotFoundError } from 'restrust';
// Express
app.get('/users/:id', guard(async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) throw new NotFoundError('User not found');
res.json(ok(user));
}));
// What happens:
// ✅ Success → your res.json() runs normally
// ❌ HttpError → sends err.status + err.toJSON()
// ❌ Unknown Error → sends 500 + { success: false, error: { status: 500, message: '...' } }Error Handler Middleware: errorHandler()
A drop-in Express error-handling middleware that converts any thrown error into a structured JSON response.
import { errorHandler } from 'restrust';
// Add as the last middleware
app.use(errorHandler());
// Options
app.use(errorHandler({
maskServerErrors: true, // (default) hide real 500 messages in production
}));When an HttpError is thrown, it sends err.status + err.toJSON(). Unknown errors become 500 with a generic message (when maskServerErrors is true).
Response Middleware: useApiResponse()
Injects response helpers directly onto res — zero extra syntax.
import { useApiResponse } from 'restrust';
app.use(useApiResponse());
// Now every route handler gets clean helpers:
app.get('/users/:id', async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) return res.notFound('User not found');
res.ok(user);
});
app.post('/users', async (req, res) => {
const user = await db.createUser(req.body);
res.created(user);
});
app.delete('/users/:id', async (req, res) => {
await db.deleteUser(req.params.id);
res.noContent();
});Available Methods
| Method | Status | Description |
|--------|--------|-------------|
| res.ok(data, meta?) | 200 | Success response |
| res.created(data, meta?) | 201 | Resource created |
| res.noContent() | 204 | No content (empty body) |
| res.paginated(data, { page, perPage, total }, meta?) | 200 | Paginated list |
| res.badRequest(msg?, code?) | 400 | Bad request error |
| res.unauthorized(msg?) | 401 | Not authenticated |
| res.forbidden(msg?) | 403 | Not authorized |
| res.notFound(msg?) | 404 | Resource not found |
| res.conflict(msg?) | 409 | Conflict |
| res.invalid(errors, msg?) | 422 | Validation error with field errors |
| res.tooManyRequests(msg?, retryAfter?) | 429 | Rate limited |
| res.serverError(msg?) | 500 | Internal server error |
| res.fail(error) | varies | From HttpError or (status, msg, code?) |
Before vs After
// ❌ Without useApiResponse
app.get('/users/:id', async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) return res.status(404).json(fail(new NotFoundError('User not found')));
res.json(ok(user));
});
// ✅ With useApiResponse
app.get('/users/:id', async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) return res.notFound('User not found');
res.ok(user);
});TypeScript Support
Out of the box, TypeScript won't know about res.ok(), res.notFound(), etc. Import the type augmentation once to fix that:
// In your app entry (e.g. app.ts or server.ts)
import 'restrust/express';
import { useApiResponse } from 'restrust';
app.use(useApiResponse());
// Now res.ok(), res.notFound(), etc. are fully typed ✅
app.get('/users/:id', async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) return res.notFound('User not found'); // ← TypeScript knows this
res.ok(user); // ← and this
});Note: Requires
@types/expressinstalled in your project.
Response Shapes
Every API response follows one of three shapes:
Success Error Paginated
───────── ───────── ─────────────
{ { {
success: true, success: false, success: true,
data: T, error: { data: T[],
meta?: {} status, pagination: {
} message, page, perPage,
code?, total, totalPages,
details?, hasNext, hasPrev
errors?, },
} meta?: {}
} }Full Express Example
import express from 'express';
import {
ok, created, paginated, guard,
NotFoundError, ValidationError,
} from 'restrust';
const app = express();
app.use(express.json());
// List with pagination
app.get('/users', guard(async (req, res) => {
const page = Number(req.query.page) || 1;
const perPage = 20;
const [users, total] = await Promise.all([
db.users.findMany({ skip: (page - 1) * perPage, take: perPage }),
db.users.count(),
]);
res.json(paginated(users, { page, perPage, total }));
}));
// Get by ID
app.get('/users/:id', guard(async (req, res) => {
const user = await db.users.findById(req.params.id);
if (!user) throw new NotFoundError('User not found', { code: 'USER_NOT_FOUND' });
res.json(ok(user));
}));
// Create
app.post('/users', guard(async (req, res) => {
const { name, email } = req.body;
const errors = [];
if (!name) errors.push({ field: 'name', message: 'Required' });
if (!email) errors.push({ field: 'email', message: 'Required' });
if (errors.length) throw new ValidationError(errors);
const user = await db.users.create({ name, email });
res.status(201).json(created(user));
}));
app.listen(3000);API Reference
Error Classes
All extend HttpError → Error. Constructor: (message?, options?)
interface HttpErrorOptions {
code?: string; // Machine-readable error code
details?: unknown; // Additional context
errors?: FieldError[];// Field-level errors
cause?: Error; // Original error
}
interface FieldError {
field: string;
message: string;
code?: string;
}Response Builders
| Function | Returns | Description |
|----------|---------|-------------|
| ok(data, meta?) | ApiSuccess<T> | Success response |
| created(data, meta?) | ApiSuccess<T> | Success (for 201) |
| fail(error) | ApiError | Error from HttpError |
| fail(status, msg, code?, details?) | ApiError | Error from raw values |
| paginated(data, { page, perPage, total }, meta?) | ApiPaginated<T> | Paginated list |
| invalid(errors, msg?) | ApiError | Validation error (422) |
| guard(handler) | Express middleware | Async error catcher |
| errorHandler(opts?) | Express error middleware | Structured error responses |
| useApiResponse() | Express middleware | Injects res.ok(), res.fail(), etc. |
Type Guards
| Method | Description |
|--------|-------------|
| HttpError.isHttpError(err) | Check if value is an HttpError |
License
MIT © hemathkumar
