Skip to main content

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:

ScenarioUse
Admin tables, report exportsres.paginated() — offset, known total
Social feeds, activity streamsres.cursorPaginated() — stable under inserts
Infinite scroll, mobile appsres.cursorPaginated() — no page-number concept
Search resultsres.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 than res.fail(). Use res.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
POST201
GET200
PUT200
PATCH200
DELETE200

// 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:

  1. withShape wrapper intercepts → transforms the envelope
  2. Calls the inner (strip) wrapper → strips sensitive fields from result
  3. 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.shape in global shapeguard() config instead of per-route withShape(), this ordering issue does not apply — shapeguard's patchResponseStrip automatically resolves the correct data key from the global shape config (fixed in v0.6.1).