Response — shapeguard
res helpers, response shapes, withShape, global config, all cases.
Response contract
shapeguard guarantees one shape for every response — success and error. Frontend writes one handler. Forever.
// SUCCESS — always this
{
success: true,
message: string,
data: T // typed from your sends: schema
}
// ERROR — always this
{
success: false,
message: string,
error: {
code: string, // stable — safe to match in frontend
message: string, // human readable
details: object | null
}
}
Configurable — see Global shape config if you need a different envelope.
res helpers
Injected by shapeguard() on every route. No import needed in controllers.
res.ok()
General success. Default 200.
res.ok({ data: user, message: 'User found' })
// HTTP 200
// { "success": true, "message": "User found", "data": { ... } }
res.ok({ data: user })
// message defaults to ""
res.ok({ data: user, status: 202 })
// override status code
res.created()
Resource created. Always 201.
res.created({ data: user, message: 'User created' })
// HTTP 201
// { "success": true, "message": "User created", "data": { ... } }
res.accepted()
Async job accepted. Always 202.
res.accepted({ data: { jobId: 'job_123' }, message: 'Export started' })
// HTTP 202
// { "success": true, "message": "Export started", "data": { "jobId": "job_123" } }
res.noContent()
No body. Always 204.
res.noContent()
// HTTP 204
// no body
res.paginated()
Offset-based pagination. Best for small-to-medium datasets with known total count.
res.paginated({
data: users, // array
total: 45, // total count in DB
page: 1, // current page
limit: 20, // items per page
})
// HTTP 200
// {
// "success": true,
// "message": "",
// "data": {
// "items": [ ... ],
// "total": 45,
// "page": 1,
// "limit": 20,
// "pages": 3 ← total pages calculated automatically
// }
// }
res.cursorPaginated()
Cursor-based pagination. Enterprise standard for large datasets, real-time feeds, and infinite scroll. Unlike offset pagination, cursor pagination remains stable when data is added or removed between pages.
res.cursorPaginated({
data: users, // array of items
nextCursor: users.at(-1)?.id ?? null, // cursor pointing to next page
prevCursor: req.query.cursor ?? null, // cursor pointing to previous page (optional)
hasMore: users.length === limit, // whether more pages exist
total: 1000, // optional — omit when count is expensive
message: 'Users found', // optional
})
// HTTP 200
// {
// "success": true,
// "message": "Users found",
// "data": {
// "items": [ ... ],
// "nextCursor": "user_abc123",
// "prevCursor": null,
// "hasMore": true,
// "total": 1000 ← only present when provided
// }
// }
Consuming cursor pages:
// GET /users?cursor=user_abc123&limit=20
const cursor = req.query.cursor as string | undefined
const users = await db.users.findMany({
take: limit + 1, // fetch one extra to detect hasMore
cursor: cursor ? { id: cursor } : undefined,
skip: cursor ? 1 : 0,
orderBy: { id: 'asc' },
})
const hasMore = users.length > limit
res.cursorPaginated({
data: users.slice(0, limit),
nextCursor: hasMore ? users[limit - 1]!.id : null,
hasMore,
})
When to use which:
| Scenario | Use |
|---|---|
| Admin tables, report exports | res.paginated() — offset, known total |
| Social feeds, activity streams | res.cursorPaginated() — stable under inserts |
| Infinite scroll, mobile apps | res.cursorPaginated() — no page-number concept |
| Search results | res.paginated() — users expect page numbers |
res.fail()
Send an error response directly from handler (without throwing). Use when you want to send an error but continue execution after.
res.fail({ code: 'INVALID_COUPON', message: 'Coupon has expired' })
// HTTP 400
// { "success": false, "message": "Coupon has expired",
// "error": { "code": "INVALID_COUPON", "message": "Coupon has expired", "details": null }}
res.fail({ code: 'QUOTA_EXCEEDED', message: 'Monthly limit reached', status: 429,
details: { resetAt: '2024-02-01T00:00:00Z' }
})
For most cases,
throw AppError.custom(...)is cleaner thanres.fail(). Useres.fail()when you need to conditionally send errors inline.
All response shapes
Success — 200
{
"success": true,
"message": "User found",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "alice@example.com",
"name": "Alice",
"role": "member",
"createdAt": "2024-01-15T10:24:31.000Z"
}
}
Created — 201
{
"success": true,
"message": "User created",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "alice@example.com",
"name": "Alice"
}
}
Paginated — 200
{
"success": true,
"message": "",
"data": {
"items": [ { "id": "...", "email": "..." }, { "id": "...", "email": "..." } ],
"total": 45,
"page": 2,
"limit": 20,
"pages": 3
}
}
Validation error — 422
{
"success": false,
"message": "Validation failed",
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": { "field": "email", "message": "Invalid email address" }
}
}
Not found — 404
{
"success": false,
"message": "User not found",
"error": {
"code": "NOT_FOUND",
"message": "User not found",
"details": null
}
}
Conflict — 409
{
"success": false,
"message": "Email already exists",
"error": {
"code": "CONFLICT",
"message": "Email already exists",
"details": null
}
}
Method not allowed — 405
{
"success": false,
"message": "Method POST is not allowed on this route",
"error": {
"code": "METHOD_NOT_ALLOWED",
"message": "Method POST is not allowed on this route",
"details": { "allowed": ["GET", "PUT", "DELETE"] }
}
}
Headers also include:
Allow: GET, PUT, DELETE
Internal error — 500 (production)
{
"success": false,
"message": "Something went wrong",
"error": {
"code": "INTERNAL_ERROR",
"message": "Something went wrong",
"details": null
}
}
Internal error — 500 (development)
{
"success": false,
"message": "Cannot read properties of undefined (reading 'id')",
"error": {
"code": "INTERNAL_ERROR",
"message": "Cannot read properties of undefined (reading 'id')",
"details": "at UserService.create (src/services/user.service.ts:24:18)"
}
}
withShape() — per-route override
Some routes need a completely different response shape. Health checks, metrics, webhooks, legacy endpoints.
import { withShape } from 'shapeguard'
Map fields from data
router.get('/health',
withShape({
ok: '{data.ok}',
uptime: '{data.uptime}',
version: '{data.version}',
}),
(req, res) => {
res.ok({ data: { ok: true, uptime: process.uptime(), version: '1.4.2' }})
// → { "ok": true, "uptime": 123.4, "version": "1.4.2" }
// no success/message/data wrapper
}
)
Raw passthrough
router.get('/ping',
withShape('raw'),
(req, res) => {
res.ok({ data: 'pong' })
// → pong
}
)
Legacy API shape
// your legacy API returns this shape — don't break existing clients
router.get('/metrics',
withShape({
status: '{data.status}',
requests: '{data.requests}',
errors: '{data.errors}',
ts: '{data.timestamp}',
}),
asyncHandler(async (req, res) => {
res.ok({ data: await MetricsService.get() })
// → { "status": "healthy", "requests": 1250, "errors": 3, "ts": 1705312345 }
})
)
withShape does not affect other routes
// only this route uses custom shape
router.get('/health', withShape({ ok: '{data.ok}' }), handler)
// all other routes still use the default envelope
router.get('/users', ...UserController.listUsers) // normal shape
router.post('/users', ...UserController.createUser) // normal shape
Global shape config
Override the default envelope for your entire app. Useful when integrating with an existing API standard.
// rename fields globally
app.use(shapeguard({
response: {
shape: {
status: '{success}', // success → status
result: '{data}', // data → result
msg: '{message}', // message → msg
}
}
}))
// all responses now use this shape
res.ok({ data: user, message: 'Created' })
// → { "status": true, "msg": "Created", "result": { ... } }
Route-level
withShape()overrides global shape config for that route.
createRouter() — auto 405
Drop-in replacement for express.Router().
Automatically returns 405 with Allow header when wrong method is used.
import { createRouter } from 'shapeguard'
const router = createRouter() // same API as express.Router()
router.get('/', ...listUsers)
router.post('/', ...createUser)
// DELETE / → 405, Allow: GET, POST
router.get('/:id', ...getUser)
router.put('/:id', ...updateUser)
router.delete('/:id', ...deleteUser)
// PATCH /:id → 405, Allow: GET, PUT, DELETE
export default router
No router.all() needed anywhere. shapeguard tracks registered methods per path automatically.
HTTP status codes
Default status per method — configurable:
// defaults
POST → 201
GET → 200
PUT → 200
PATCH → 200
DELETE → 200
// override globally
app.use(shapeguard({
response: {
statusCodes: {
POST: 200, // prefer 200 over 201
DELETE: 204, // prefer 204 no-content on delete
}
}
}))
// override per call
res.ok({ data: user, status: 202 }) // explicit override
res.created({ data: user }) // always 201, ignores config
res.noContent() // always 204, ignores config
Middleware ordering: withShape() and validate()
BUG #12 fix — documented here to prevent a silent security hole.
Both withShape() and validate({ response: ... }) patch res.json() by wrapping it.
The last one to run in the middleware chain becomes the outermost wrapper — and the
outermost wrapper runs first when res.json() is eventually called by the handler.
Correct order — validate() first, then withShape():
router.get('/users/:id',
validate(GetUserRoute), // ← runs first: registers strip wrapper (inner)
withShape({ result: '{data}' }), // ← runs second: registers shape wrapper (outer)
handler,
)
Call sequence when res.json(body) fires:
withShapewrapper intercepts → transforms the envelope- Calls the inner (strip) wrapper → strips sensitive fields from
result - Calls the original
res.json→ sends the final response
Wrong order — withShape() first, validate() second:
router.get('/users/:id',
withShape({ result: '{data}' }), // ← runs first: inner wrapper
validate(GetUserRoute), // ← runs second: outer wrapper — checks for 'data' key
handler,
)
// patchResponseStrip looks for 'data' in the already-shaped body.
// The key is now 'result'. Strip is silently skipped. Sensitive fields leak.
Rule: always mount validate() before withShape() on the same route.
Note: when using
response.shapein globalshapeguard()config instead of per-routewithShape(), this ordering issue does not apply — shapeguard'spatchResponseStripautomatically resolves the correct data key from the global shape config (fixed in v0.6.1).