Appearance
Error handling
Scattered
try/catchwith five different JSON shapes is how APIs become annoying for FE. One error middleware, one envelope.
The four-argument middleware
ts
// must be registered AFTER routes
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err)
const status = err instanceof HttpError ? err.status : 500
const message = err instanceof HttpError ? err.message : 'server error'
res.status(status).json({ error: message })
})Express detects error middleware by the 4 parameters. Skip one and it won't work the way you expect. 🤔
A tiny HttpError helper
ts
export class HttpError extends Error {
constructor(
public status: number,
message: string,
) {
super(message)
}
}
// usage in a route
throw new HttpError(404, 'note not found')Then async routes:
ts
notesRouter.get('/:id', async (req, res, next) => {
try {
const note = await findNote(req.params.id)
if (!note) throw new HttpError(404, 'note not found')
res.json({ note })
} catch (err) {
next(err)
}
})Async wrapper (less boilerplate)
ts
const asyncHandler =
(fn: (req: Request, res: Response, next: NextFunction) => Promise<void>) =>
(req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next)
}
notesRouter.get(
'/:id',
asyncHandler(async (req, res) => {
const note = await findNote(req.params.id)
if (!note) throw new HttpError(404, 'note not found')
res.json({ note })
}),
)As you can see, catch(next) is the whole trick.
What the SPA should expect
Pick a stable error shape and stick to it:
json
{ "error": "email already registered" }Optional later: { error, code, details } for field validation. Consistency > cleverness.
Don't leak stack traces to clients
Log err server-side. Send a short message to the browser. In production, generic 500 text is fine; detailed messages for 4xx are usually OK.
404 fallthrough
ts
app.use('/api', (_req, res) => {
res.status(404).json({ error: 'not found' })
})
app.use(errorMiddleware)Order: routers → API 404 → error middleware.
Next: env files, secrets, and CORS so your Vite app can actually call this thing.
Best,
-- ł.