Error Handling
Slingshot has two main error paths:
- request-shape failures handled by the shared validation hook
- application failures handled by the framework’s
app.onError
For framework-managed apps, prefer HttpError and ValidationError. Those are the types the built-in error handler understands today.
How errors flow
Section titled “How errors flow”Routers created with createRouter() use Slingshot’s shared defaultHook. That means:
- request params, query strings, and JSON bodies are validated before your handler runs
- failed validation returns HTTP
400 - handlers that throw
HttpErrorare converted into structured JSON responses - handlers that throw
ValidationErrorreuse the active validation formatter - all other thrown values fall through to Slingshot’s generic
500response
Auth middleware such as userAuth and requireRole(...) returns 401 and 403 responses directly rather than throwing.
Validation errors
Section titled “Validation errors”The default validation formatter produces this shape:
{ "error": "Invalid email, Required", "details": [ { "path": "email", "message": "Invalid email" }, { "path": "name", "message": "Required" } ], "requestId": "req_a1b2c3d4"}DefaultValidationErrorBody from @lastshotlabs/slingshot describes that contract exactly:
| Field | Type | Meaning |
|---|---|---|
error | string | Comma-joined summary of every issue |
details | { path: string; message: string }[] | Per-field breakdown |
requestId | string | Correlation ID from the request pipeline |
path is built with dot-separated segments because the implementation uses issue.path.join('.').
Customizing validation responses
Section titled “Customizing validation responses”Validation response customization lives under validation.formatError, not a top-level validationErrorFormatter option.
import { type ValidationErrorFormatter, defineApp } from '@lastshotlabs/slingshot';
const formatError: ValidationErrorFormatter = (issues, requestId) => ({ type: 'validation_error', requestId, fields: issues.map(issue => ({ path: issue.path.join('.'), message: issue.message, })),});
export default defineApp({ validation: { formatError, },});If your formatter throws, Slingshot falls back to the built-in formatter rather than surfacing a 500.
Throwing typed HTTP errors
Section titled “Throwing typed HTTP errors”Use HttpError when your handler needs to fail with a specific status code and an optional machine-readable code.
import { HttpError, createRouter } from '@lastshotlabs/slingshot';import { userAuth } from '@lastshotlabs/slingshot-auth';import { getRequestTenantId } from '@lastshotlabs/slingshot-core';
type Post = { id: string; tenantId: string | null; title: string;};
const posts = new Map<string, Post>();
export const router = createRouter();
router.get('/posts/:id', userAuth, c => { const id = c.req.param('id'); const post = posts.get(id);
if (!post) { throw new HttpError(404, 'Post not found', 'POST_NOT_FOUND'); }
if (post.tenantId !== getRequestTenantId(c)) { throw new HttpError(403, 'Access denied', 'POST_ACCESS_DENIED'); }
return c.json(post, 200);});HttpError becomes:
{ error, requestId }for a normal framework error{ error, requestId, code }when you pass acode
Reusing the validation path from service code
Section titled “Reusing the validation path from service code”When validation happens outside the route layer, throw ValidationError with the raw Zod issues.
import { z } from 'zod';import { ValidationError } from '@lastshotlabs/slingshot-core';
export async function validateBody<T extends z.ZodType>( schema: T, req: Request,): Promise<z.output<T>> { const body: unknown = await req.json(); const result = schema.safeParse(body);
if (!result.success) { throw new ValidationError( result.error.issues as ConstructorParameters<typeof ValidationError>[0], ); }
return result.data;}This feeds back into the same formatter configured in validation.formatError.
Authentication errors
Section titled “Authentication errors”These are the common middleware-level responses:
| Middleware | Status | Body |
|---|---|---|
userAuth | 401 | { "error": "Unauthorized" } |
requireRole('admin') without a user | 401 | { "error": "Unauthorized" } |
requireRole('admin') with the wrong role | 403 | { "error": "Forbidden" } |
import { createRouter } from '@lastshotlabs/slingshot';import { requireRole, userAuth } from '@lastshotlabs/slingshot-auth';
const router = createRouter();
router.delete('/admin/users/:id', userAuth, requireRole('admin'), c => { return c.body(null, 204);});
router.get('/super/dashboard', userAuth, requireRole.global('superadmin'), c => { return c.json({ ok: true }, 200);});requireRole.global(...) ignores tenant scoping intentionally and checks app-wide roles only.
OpenAPI error schemas
Section titled “OpenAPI error schemas”Use named schemas for reusable error responses so clients see consistent $ref components throughout the document.
import { z } from 'zod';import { ErrorResponse } from '@lastshotlabs/slingshot-auth';
export { ErrorResponse };
export const ValidationErrorResponse = z.object({ error: z.string(), requestId: z.string(), details: z.array( z.object({ path: z.string(), message: z.string(), }), ),});
export const NotFoundErrorResponse = z.object({ error: z.string(), requestId: z.string(),});ErrorResponse from @lastshotlabs/slingshot-auth is the canonical built-in auth error shape: { error: string }.
Status-code expectations
Section titled “Status-code expectations”For framework-managed APIs, consumers can generally expect:
| Status | Typical source | Body shape |
|---|---|---|
400 | route validation or ValidationError | { error, details, requestId } by default |
401 | auth middleware | { error: "Unauthorized" } |
403 | authz middleware or HttpError(403, ...) | auth middleware returns { error: "Forbidden" }; HttpError also includes requestId |
404 | missing resource | { error, requestId } when thrown via HttpError |
409 | conflict | { error, requestId, code? } when thrown via HttpError |
500 | unhandled exception | { error: "Internal Server Error", requestId } |