formatted-response
v2.1.0
Published
Create formatted, consistently-shaped JSON responses — synchronously or as promises. Zero runtime dependencies.
Maintainers
Readme
formatted-response
Create formatted, consistently-shaped JSON responses for your application — synchronously or as promises. Zero runtime dependencies. Written in TypeScript, published as dual ESM + CommonJS.
Features
- Zero runtime dependencies
- Fully typed — the
success/error/warning/... methods infer whether they return a value or aPromiseof one, from your config - Works with
require(),import, and TypeScript out of the box - Node.js ≥ 20.19
Installation
npm install formatted-response
# or
yarn add formatted-responseUsage
ESM
import FormattedResponse from 'formatted-response'
const response = new FormattedResponse()
// response.promise defaults to true, so this returns a Promise
const payload = await response.success('MY.DATA', { myData: 'My data!' })
// => { status: 200, message: 'MY.DATA.OK', type: 'success', data: { myData: 'My data!' } }CommonJS
const FormattedResponse = require('formatted-response')
const response = new FormattedResponse({ promise: false })
console.log(response.success('MY.DATA', { myData: 'My data!' }))
// => { status: 200, message: 'MY.DATA.OK', type: 'success', data: { myData: 'My data!' } }TypeScript
The return type of every method is inferred from promise in the config you pass the constructor — no casts needed for the common case:
import FormattedResponse from 'formatted-response'
const sync = new FormattedResponse({ promise: false })
sync.success('M') // ResponsePayload — synchronous
const async_ = new FormattedResponse() // promise: true is the default
async_.success('M') // Promise<ResponsePayload>Inference works from a config object literal. If your config comes from a variable typed as FormattedResponseOptions, TypeScript can no longer know which branch you're on at compile time, and the return type falls back to ResponsePayload | Promise<ResponsePayload>. Three ways around that, in order of preference:
// 1. Best — a runtime-guaranteed factory
const R = FormattedResponse.sync(runtimeConfig) // always ResponsePayload
const A = FormattedResponse.async(runtimeConfig) // always Promise<ResponsePayload>
// 2. `as const` on the literal
const cfg = { promise: false } as const
const R = new FormattedResponse(cfg)
// 3. The universal escape hatch — works whether or not it's actually a Promise
const r = await response.success('SAVED', { id: 1 })Both new FormattedResponse(config) and FormattedResponse(config) (without new) work, for compatibility with 1.x — instanceof checks still succeed either way.
API
Every method's last argument is an optional promise: boolean, which overrides the instance's config.promise for that one call.
| Method | Adds to message | status | type |
|---|---|---|---|
| response(message, data, type, status, promise?) | (nothing — full control) | as given | as given |
| get(message, data?, promise?) | config.get.ok | config.ok.status | (none) |
| success(message, data?, promise?) | config.ok.suffix | config.ok.status | config.types.ok |
| error(message, error?, promise?) | config.ko.suffix | config.ko.status | config.types.ko |
| warning(message, error?, promise?) | config.warn.suffix | config.warn.status | config.types.warn |
| notFound(message, error?, promise?) | config.notFound.suffix | config.notFound.status | config.types.notFound |
const response = new FormattedResponse({ promise: false })
response.success('MY.DATA', { myData: 'My data!' })
// => { status: 200, message: 'MY.DATA.OK', type: 'success', data: { myData: 'My data!' } }
response.get('MY.DATA', { myData: 'My data!' })
// => { status: 200, message: 'MY.DATA.GET.OK', data: { myData: 'My data!' } }
response.error('MY.DATA', new Error('My Error!'))
// => { status: 400, message: 'MY.DATA.KO', type: 'error' }
// (the `error` message itself is only included when `debug: true` — see Configuration)
response.warning('MY.DATA', new Error('My Error!'))
// => { status: 403, message: 'MY.DATA.WARN', type: 'warning' }
response.notFound('MY.DATA', new Error('My Error!'))
// => { status: 404, message: 'MY.DATA.ERROR', type: 'error' }
response.response('MY.DATA', { myData: 'My data!' }, 'my-type', 200)
// => { status: 200, message: 'MY.DATA', type: 'my-type', data: { myData: 'My data!' } }[!WARNING]
debug: falsehides the error'smessage, but not itserrorsproperty. If the error you pass has one, it is always copied into the response, asdata.errors(or top-levelerrorswhendebug: true):const err = Object.assign(new Error('Validation failed'), { errors: [{ path: 'email', value: '[email protected]' }] }) response.error('USER.CREATE', err) // => { status: 400, message: 'USER.CREATE.KO', type: 'error', data: { errors: [{ path: 'email', value: '[email protected]' }] } }ORM and validation libraries (Sequelize, Mongoose, …) put internal details there, such as rejected values, model and column names. Don't pass their errors directly to a response sent to clients. Map them first to what the client should see:
response.error('USER.CREATE', { errors: err.errors.map(e => ({ field: e.path })) })
data is nested under a data key automatically, unless it already has a data or error key of its own, in which case it's spread at the top level:
response.success('X', { count: 1 }) // => {..., data: { count: 1 }}
response.success('X', { data: { count: 1 } }) // => {..., data: { count: 1 }} (same result)
response.response('X', { data: { a: 1 }, extra: 2 }, 't', 200)
// => { status: 200, message: 'X', type: 't', data: { a: 1 }, extra: 2 }Any HTTP status
status(code, message, data?, promise?) sends any code from 100 to 599. Below 400 the third argument is data. From 400 up it's parsed as an error, exactly like error(), so debug and the errors rules above apply. The suffix is the code's name from the IANA registry, and the type is types.ok below 400 and types.ko from 400 up:
response.status(201, 'USER', { id: 1 })
// => { status: 201, message: 'USER.CREATED', type: 'success', data: { id: 1 } }
response.status(FormattedResponse.HttpStatus.CONFLICT, 'USER', err)
// => { status: 409, message: 'USER.CONFLICT', type: 'error' }A code outside the registry (e.g. 299) gets no suffix. A code outside 100–599 throws a RangeError.
The common codes also have named methods, with the same arguments as success() or error():
| Takes data | Takes an error |
|---|---|
| created (201), accepted (202), noContent (204) | badRequest (400), unauthorized (401), forbidden (403), conflict (409), unprocessableContent (422), tooManyRequests (429), internalServerError (500), serviceUnavailable (503) |
FormattedResponse.HttpStatus maps every registry name to its code (HttpStatus.CREATED === 201). error(), warning() and notFound() are unchanged: they still use .KO, .WARN and .ERROR.
Configuration
All defaults live in FormattedResponse.defaults (or the deprecated formatted-response/config subpath). Pass a partial override to the constructor — it's deep-merged over the defaults.
const response = new FormattedResponse({
debug: true,
promise: false,
types: { ok: 'alert-success', ko: 'alert-danger', warn: 'alert-warning', notFound: 'alert-notFound' },
get: { ok: '.GET.SUCCESS', ko: '.GET.ERROR', warn: '.GET.WARNING' },
ok: { status: 200, suffix: '.SUCCESS' },
ko: { status: 400, suffix: '.ERROR' },
warn: { status: 403, suffix: '.WARNING' },
notFound: { status: 404, suffix: '.ERROR' },
dateFields: ['created_at', 'created', 'updated_at', 'updated']
})| Key | Default | Meaning |
|---|---|---|
| debug | false | When true, error/warning/notFound include the underlying Error's message under an error key |
| promise | true | Default return mode for every method — synchronous value vs Promise |
| types.{ok,ko,warn,notFound} | success / error / warning / error | The type field emitted by success/error/warning/notFound |
| get.ok | .GET.OK | Suffix appended to message by get() |
| ok.{status,suffix} | 200 / .OK | Used by success() |
| ko.{status,suffix} | 400 / .KO | Used by error() |
| warn.{status,suffix} | 403 / .WARN | Used by warning() |
| notFound.{status,suffix} | 404 / .ERROR | Used by notFound() |
| dateFields | ['created_at', 'created', 'updated_at', 'updated'] | Keys in data whose value, if a real Date, is converted to an ISO string |
| statuses | (none) | Per-code overrides for status() and the named status methods: { 409: { suffix: '.TAKEN', type: 'warning' } } |
get.ko, get.warn, and auth.* are accepted in the config shape for backwards compatibility but are not read by any method — reserved for future use.
Types
The package ships full TypeScript types at the package root, plus at the formatted-response/types subpath for advanced usage:
import type {
FormattedResponseConfig, // the fully-resolved config
FormattedResponseOptions, // partial config accepted by the constructor
BaseResponse, TypedResponse, ErrorResponse, // response payload shapes
ErrorInput, // what error()/warning()/notFound() accept
StatusResponse, // what status() returns
StatusOverride, // one entry of config.statuses
HttpStatusCode, HttpStatusName, // registry codes and names
Resolved // the sync/promise resolution helper
} from 'formatted-response/types'Migration from 1.x
Breaking changes in 2.0.0:
- Node ≥ 20.19 is required.
ObjDeepMerge,_isObject,_isDate,_promise, and_parseErrorsare no longer public on the prototype. They were internal helpers (four of them_-prefixed already); if you depended on them, they're easy to reimplement — seesrc/internal/merge.ts.datapassed intosuccess/error/etc. is no longer mutated. In 1.x, converting adateFieldsvalue to an ISO string wrote back into your own object; it no longer does.- Non-plain objects at keys not listed in
dateFields(Date,Map,Set,Buffer, class instances) are now preserved by reference instead of being silently flattened to{}— this fixes a crash onBuffervalues and a silent data-loss bug onDate/Mapvalues, but it does change output for anyone who was relying on the old flattening behavior. config.dateFieldsis now copied per instance instead of being shared by reference with the defaults — mutating it on one instance no longer poisons every other instance in the process.require('formatted-response/config')still works but is deprecated; useFormattedResponse.defaultsinstead.- Only the documented entry points (
.,./config,./types) are importable — deep paths likeformatted-response/src/responseno longer resolve.
Everything else — the method signatures, the default config values, the message/status/type shapes — is unchanged.
Changelog
See CHANGELOG.md.
Contributing
Issues and PRs welcome at github.com/Kareylo/formatted-response.
npm install # or: yarn install
npm run verify # lint + typecheck + test + build + package checksLicense
MIT © Corentin Nazé
