Exception Handling
Slingshot has one rule for handlers: throw the error you want to surface. The framework maps it to a structured HTTP response. You don’t return error objects, you don’t write try/catch around every database call.
The model
Section titled “The model”Three things can come out of a handler:
- A successful response (
respond.json(...),c.json(...), etc.) - A thrown
HttpError— the framework knows the status code and shape - A thrown unknown error — logged, returned as
500 Internal Server Error
Validation errors are a special case the framework throws on your behalf when input
parsing fails — they map to 400 with structured issues.
HttpError
Section titled “HttpError”The canonical way to surface a typed HTTP failure:
// @skip-typecheckimport { z } from 'zod';import { HttpError, route } from '@lastshotlabs/slingshot';
route.get({ path: '/posts/:id', request: { params: z.object({ id: z.string().uuid() }) }, handler: async ({ params, respond }) => { const post = await fetchPost(params.id); if (!post) { throw new HttpError(404, 'Post not found', 'POST_NOT_FOUND'); } return respond.json(post); },});HttpError becomes:
HTTP/1.1 404 Not FoundContent-Type: application/json
{ "error": "Post not found", "code": "POST_NOT_FOUND", "requestId": "req_abc123"}The code is optional — set it for stable machine-readable identifiers your clients
match against.
Common patterns
Section titled “Common patterns”// @skip-typecheckthrow new HttpError(401, 'Sign in to continue');throw new HttpError(403, 'You do not own this resource');throw new HttpError(404, 'Resource not found');throw new HttpError(409, 'Username already taken', 'USERNAME_TAKEN');throw new HttpError(422, 'Cannot publish without content');throw new HttpError(429, 'Too many requests, slow down');For domain-specific errors, subclass HttpError:
import { HttpError } from '@lastshotlabs/slingshot';
export class PostNotPublishedError extends HttpError { constructor(postId: string) { super(409, `Post ${postId} is not published`, 'POST_NOT_PUBLISHED'); }}Throwing new PostNotPublishedError(id) produces a typed 409 with the canonical message.
ValidationError
Section titled “ValidationError”ValidationError is the framework’s subclass of HttpError for input validation. You
rarely throw it directly — Zod validation throws it for you when a request body, query,
or params fails to parse. It maps to 400 with the structured issues payload described
in Validation.
If you ever need to throw it manually (e.g., cross-field validation that Zod can’t express):
// @skip-typecheckimport { z } from 'zod';import { ValidationError, route } from '@lastshotlabs/slingshot';
route.post({ path: '/bookings', request: { body: z.object({ start: z.string(), end: z.string() }) }, handler: async ({ body, respond }) => { if (new Date(body.end) <= new Date(body.start)) { throw new ValidationError([ { path: ['body', 'end'], code: 'custom', message: 'end must be after start', }, ]); } return respond.json({ ok: true }); },});Unhandled errors
Section titled “Unhandled errors”Anything that isn’t an HttpError is treated as a programming bug. The framework:
- Logs the full stack via the structured logger
- Returns
500 Internal Server Errorwith the request ID - Does not leak the error message — clients see only
"Internal Server Error"
This is deliberate. Database driver errors, runtime exceptions, and unexpected nulls should not become user-facing messages. Surface them through the logger and observability, not through the wire.
Customizing the error response
Section titled “Customizing the error response”The default 500 body is { error: "Internal Server Error", requestId: "req_..." }.
Override the format via a Hono onError handler from a plugin:
// @skip-typecheckimport type { SlingshotPlugin } from '@lastshotlabs/slingshot';
export const errorFormatterPlugin: SlingshotPlugin = { name: 'error-formatter', setupMiddleware({ app }) { app.onError((err, c) => { // The framework already handles HttpError and ValidationError before this fires — // this hook only sees genuinely unhandled exceptions. const requestId = c.get('requestId'); console.error('[unhandled]', err); return c.json( { ok: false, requestId, error: { type: 'internal', message: 'Something went wrong.' }, }, 500, ); }); },};HttpError and ValidationError are caught by the framework’s own onError chain and
formatted with the appropriate status — your plugin only sees genuine 500s.
Testing exceptions
Section titled “Testing exceptions”Use app.request() from createApp() and assert on status + body:
// @skip-typecheckimport { describe, expect, test } from 'bun:test';import { createApp } from '@lastshotlabs/slingshot';import { blogPackage } from '../src/blog/package';
describe('GET /posts/:id', () => { test('returns 404 when post does not exist', async () => { const { app, ctx } = await createApp({ packages: [blogPackage] });
const res = await app.request('/posts/00000000-0000-0000-0000-000000000000'); expect(res.status).toBe(404); expect(await res.json()).toMatchObject({ error: 'Post not found', code: 'POST_NOT_FOUND', });
await ctx.destroy(); });});What’s next
Section titled “What’s next”- Validation — input parsing and
400responses - Routes and Handlers — the route DSL
- Testing — full test setup with
createApp