RFC 9457 in Node.js and TypeScript: Problem Details Error Response Examples
Learn how to use RFC 9457 Problem Details in Node.js and TypeScript with JSON examples, validation errors, exception handling, and cleaner REST API responses.
RFC 9457 in Node.js and TypeScript: problem details error response examples
Most API error responses start simple.
A controller throws an error. The API returns a status code. Someone adds a message field. Then validation errors need field names. Authentication needs a different shape. A webhook needs a trace ID. Before long, the API has five error formats and frontend developers have to write defensive code for all of them.
RFC 9457 gives you a cleaner pattern: Problem Details for HTTP APIs.
Instead of inventing a new JSON shape for every project, you return a small standard body with fields like type, title, status, detail, and instance. You can still add your own fields when the client needs more context.
This guide shows how to use RFC 9457 in a Node.js and TypeScript API. The examples use Express, but the same response shape works in Fastify, NestJS, Hono, Koa, or any other Node framework.
If you want the general JSON pattern first, read RFC 9457 problem details JSON examples. For broader REST design guidance, see REST API error response format best practices and examples.
What is Problem Details in Node.js?
RFC 9457 defines a standard format for HTTP API errors. The media type is:
application/problem+json
A basic problem details response looks like this:
{
"type": "https://api.example.com/problems/invalid-request",
"title": "Invalid request",
"status": 400,
"detail": "The request body is missing the email field.",
"instance": "/users"
}
The standard fields are:
| Field | Meaning |
|---|---|
type | A URI that identifies the kind of problem |
title | A short human-readable summary |
status | The HTTP status code |
detail | A human-readable explanation for this specific request |
instance | A URI that identifies this specific occurrence, often the request path |
You can also add custom fields. For example, validation errors often need an invalidParams array so the client can point users to the exact fields that failed.
In TypeScript, it is useful to model this shape directly:
export type ProblemDetail = {
type: string;
title: string;
status: number;
detail?: string;
instance?: string;
[extension: string]: unknown;
};
That last line allows extension fields such as errorCode, traceId, or invalidParams.
Basic Problem Details example
Here is a small helper for building a Problem Details response in Node.js:
import type { Request, Response } from "express";
export type ProblemDetail = {
type: string;
title: string;
status: number;
detail?: string;
instance?: string;
[extension: string]: unknown;
};
export function sendProblem(
req: Request,
res: Response,
problem: Omit<ProblemDetail, "instance"> & { instance?: string }
) {
const body: ProblemDetail = {
...problem,
instance: problem.instance ?? req.originalUrl,
};
return res
.status(problem.status)
.type("application/problem+json")
.json(body);
}
A controller can now return the same JSON shape every time:
app.get("/users/:id", async (req, res) => {
const user = await userRepository.findById(req.params.id);
if (!user) {
return sendProblem(req, res, {
type: "https://api.example.com/problems/user-not-found",
title: "User not found",
status: 404,
detail: `No user exists with id ${req.params.id}.`,
});
}
return res.json(user);
});
The client receives:
{
"type": "https://api.example.com/problems/user-not-found",
"title": "User not found",
"status": 404,
"detail": "No user exists with id 123.",
"instance": "/users/123"
}
After generating your Node.js error response, paste the JSON into the REST API Error Response Format Checker to confirm that the format is consistent and developer-friendly.
Returning Problem Details from a controller
For small APIs, returning Problem Details directly from a controller is fine. It keeps the behavior obvious.
app.post("/users", async (req, res) => {
const { email, name } = req.body;
if (!email) {
return sendProblem(req, res, {
type: "https://api.example.com/problems/missing-field",
title: "Missing required field",
status: 400,
detail: "The email field is required.",
field: "email",
});
}
const user = await userRepository.create({ email, name });
return res.status(201).json(user);
});
This is a good start, but it gets repetitive. Most production APIs end up with global error handling so controllers can throw typed errors instead of manually building JSON every time.
Global error handling with Express middleware
A cleaner TypeScript pattern is to create an application error class.
export class ApiError extends Error {
public readonly status: number;
public readonly type: string;
public readonly title: string;
public readonly extensions: Record<string, unknown>;
constructor(options: {
status: number;
type: string;
title: string;
detail?: string;
extensions?: Record<string, unknown>;
}) {
super(options.detail ?? options.title);
this.status = options.status;
this.type = options.type;
this.title = options.title;
this.extensions = options.extensions ?? {};
}
}
Then add a global error handler:
import type { ErrorRequestHandler } from "express";
export const problemDetailsHandler: ErrorRequestHandler = (
err,
req,
res,
next
) => {
if (res.headersSent) {
return next(err);
}
if (err instanceof ApiError) {
return res
.status(err.status)
.type("application/problem+json")
.json({
type: err.type,
title: err.title,
status: err.status,
detail: err.message,
instance: req.originalUrl,
...err.extensions,
});
}
return res
.status(500)
.type("application/problem+json")
.json({
type: "https://api.example.com/problems/internal-server-error",
title: "Internal server error",
status: 500,
detail: "Something went wrong while processing the request.",
instance: req.originalUrl,
});
};
Register it after your routes:
app.use(problemDetailsHandler);
Now your controller can throw an error:
app.get("/users/:id", async (req, res, next) => {
try {
const user = await userRepository.findById(req.params.id);
if (!user) {
throw new ApiError({
status: 404,
type: "https://api.example.com/problems/user-not-found",
title: "User not found",
detail: `No user exists with id ${req.params.id}.`,
});
}
return res.json(user);
} catch (error) {
return next(error);
}
});
If you use Express 5, async route errors are passed to the error handler automatically. In Express 4, use try/catch, a small async wrapper, or a library that forwards rejected promises to next().
Validation error response example with Zod
Validation errors need more than a single message. The client usually needs to know which fields failed and why.
Here is an example using Zod:
import { z } from "zod";
const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(2),
age: z.number().int().min(18).optional(),
});
app.post("/users", async (req, res, next) => {
try {
const result = createUserSchema.safeParse(req.body);
if (!result.success) {
throw new ApiError({
status: 422,
type: "https://api.example.com/problems/validation-error",
title: "Validation failed",
detail: "One or more fields failed validation.",
extensions: {
invalidParams: result.error.issues.map((issue) => ({
name: issue.path.join("."),
reason: issue.message,
})),
},
});
}
const user = await userRepository.create(result.data);
return res.status(201).json(user);
} catch (error) {
return next(error);
}
});
The response body:
{
"type": "https://api.example.com/problems/validation-error",
"title": "Validation failed",
"status": 422,
"detail": "One or more fields failed validation.",
"instance": "/users",
"invalidParams": [
{
"name": "email",
"reason": "Invalid email address"
},
{
"name": "name",
"reason": "String must contain at least 2 character(s)"
}
]
}
This is much easier for frontend developers to use than a plain "Invalid request" message. They can map invalidParams to form fields, show field-level messages, and log the full response for debugging.
After you build a validation error response like this, paste it into the REST API Error Response Format Checker. It helps catch missing fields, inconsistent naming, and vague messages before your API clients have to deal with them.
404 not found example
A 404 should be specific enough to help the developer, but not so detailed that it leaks private data.
Good example:
{
"type": "https://api.example.com/problems/user-not-found",
"title": "User not found",
"status": 404,
"detail": "No user exists with id 123.",
"instance": "/users/123"
}
For public resources, that level of detail is usually fine.
For private resources, be more careful. If the user does not have access to a resource, you may want to return a generic 404 so the response does not confirm whether the resource exists.
{
"type": "https://api.example.com/problems/resource-not-found",
"title": "Resource not found",
"status": 404,
"detail": "The requested resource could not be found.",
"instance": "/projects/private-project-id"
}
That still follows RFC 9457. It just avoids exposing unnecessary information.
422 validation-style error example
Use 422 Unprocessable Content when the request is syntactically valid JSON, but the API cannot process it because the content does not pass business rules.
For example, this request is valid JSON:
{
"email": "sam@example.com",
"plan": "enterprise",
"seats": 1
}
But your business rules might require at least 5 seats for an enterprise plan.
A 422 response could look like this:
{
"type": "https://api.example.com/problems/plan-seat-limit",
"title": "Plan seat limit not met",
"status": 422,
"detail": "The enterprise plan requires at least 5 seats.",
"instance": "/subscriptions",
"invalidParams": [
{
"name": "seats",
"reason": "Enterprise subscriptions require at least 5 seats."
}
]
}
In TypeScript:
app.post("/subscriptions", async (req, res, next) => {
try {
const { email, plan, seats } = req.body;
if (plan === "enterprise" && seats < 5) {
throw new ApiError({
status: 422,
type: "https://api.example.com/problems/plan-seat-limit",
title: "Plan seat limit not met",
detail: "The enterprise plan requires at least 5 seats.",
extensions: {
invalidParams: [
{
name: "seats",
reason: "Enterprise subscriptions require at least 5 seats.",
},
],
},
});
}
const subscription = await subscriptionRepository.create({
email,
plan,
seats,
});
return res.status(201).json(subscription);
} catch (error) {
return next(error);
}
});
A simple rule of thumb:
| Situation | Status code |
|---|---|
| JSON is malformed | 400 Bad Request |
| JSON is valid, but fields fail validation | 422 Unprocessable Content |
| Auth token is missing or invalid | 401 Unauthorized |
| User is authenticated but not allowed | 403 Forbidden |
| Resource does not exist | 404 Not Found |
| Resource state conflicts with the request | 409 Conflict |
The exact choice depends on your API, but do not switch shapes when you switch status codes. Keep the Problem Details format consistent.
Adding custom fields to Problem Details
RFC 9457 lets you add extension fields. This is where the format becomes practical.
Common custom fields include:
{
"type": "https://api.example.com/problems/rate-limit-exceeded",
"title": "Rate limit exceeded",
"status": 429,
"detail": "You have exceeded the allowed number of requests.",
"instance": "/reports",
"errorCode": "RATE_LIMIT_EXCEEDED",
"traceId": "req_01HZR8ZKQ9QW7Y3D0J4F2E6B8A",
"retryAfterSeconds": 60
}
A few guidelines:
- Use
typefor the problem category. - Use
errorCodeif your clients need a stable machine-readable code. - Use
traceIdwhen support or engineering teams need to find the request in logs. - Use
invalidParamsfor field-level validation errors. - Do not put stack traces in public responses.
Here is an example helper that always adds a trace ID if one exists on the request:
import type { Request } from "express";
type RequestWithTrace = Request & {
traceId?: string;
};
export function toProblemDetail(
req: RequestWithTrace,
error: ApiError
): ProblemDetail {
return {
type: error.type,
title: error.title,
status: error.status,
detail: error.message,
instance: req.originalUrl,
traceId: req.traceId,
...error.extensions,
};
}
This gives developers enough information to debug the error without exposing internals.
Common Node.js API error response mistakes
The biggest problem is not choosing the wrong field name. It is being inconsistent.
Here are the mistakes I see most often.
Returning a different shape for every error
This is hard to consume:
{
"error": "User not found"
}
{
"message": "Validation failed",
"fields": {
"email": "Invalid email"
}
}
{
"success": false,
"data": null,
"errorMessage": "Server error"
}
Pick one format. RFC 9457 gives you that format.
Sending 200 OK for errors
Do not return a successful HTTP status code for a failed request.
This forces clients to parse the body to know whether the request worked. HTTP status codes exist so clients, logs, gateways, and monitoring tools can understand the response quickly.
Bad:
{
"success": false,
"error": "User not found"
}
With HTTP status:
200 OK
Better:
{
"type": "https://api.example.com/problems/user-not-found",
"title": "User not found",
"status": 404,
"detail": "No user exists with id 123.",
"instance": "/users/123"
}
With HTTP status:
404 Not Found
Leaking stack traces
This is fine in local development:
{
"error": "Cannot read properties of undefined",
"stack": "TypeError: Cannot read properties of undefined..."
}
It is not fine in production.
In production, return a safe Problem Details body and log the real error on the server.
{
"type": "https://api.example.com/problems/internal-server-error",
"title": "Internal server error",
"status": 500,
"detail": "Something went wrong while processing the request.",
"instance": "/checkout",
"traceId": "req_01HZR8ZKQ9QW7Y3D0J4F2E6B8A"
}
Using vague messages
A response like this is technically an error response, but it does not help much:
{
"type": "https://api.example.com/problems/bad-request",
"title": "Bad request",
"status": 400,
"detail": "Invalid input."
}
Better:
{
"type": "https://api.example.com/problems/validation-error",
"title": "Validation failed",
"status": 422,
"detail": "One or more fields failed validation.",
"invalidParams": [
{
"name": "email",
"reason": "Enter a valid email address."
}
]
}
Good API errors save time. They tell the client what failed, where it failed, and what can be fixed.
How to test your output with the REST API Error Response Format Checker
Once your API returns Problem Details JSON, copy one real response and test it with the REST API Error Response Format Checker.
Use it for examples like:
{
"type": "https://api.example.com/problems/validation-error",
"title": "Validation failed",
"status": 422,
"detail": "One or more fields failed validation.",
"instance": "/users",
"invalidParams": [
{
"name": "email",
"reason": "Enter a valid email address."
}
]
}
Check for:
- Missing standard fields
- Mismatched HTTP status and body status
- Unclear
titleordetailvalues - Inconsistent validation field names
- Extra fields that are useful for clients
You can also compare your response against the examples in REST API error response format best practices and examples. If you want the framework-agnostic JSON patterns behind these helpers, see RFC 9457 problem details explained with JSON examples so your status codes, logs, and response bodies work together.
Complete Express and TypeScript example
Here is a small but usable setup.
import express from "express";
import { z } from "zod";
import type { ErrorRequestHandler, Request, Response } from "express";
type ProblemDetail = {
type: string;
title: string;
status: number;
detail?: string;
instance?: string;
[extension: string]: unknown;
};
class ApiError extends Error {
public readonly status: number;
public readonly type: string;
public readonly title: string;
public readonly extensions: Record<string, unknown>;
constructor(options: {
status: number;
type: string;
title: string;
detail?: string;
extensions?: Record<string, unknown>;
}) {
super(options.detail ?? options.title);
this.status = options.status;
this.type = options.type;
this.title = options.title;
this.extensions = options.extensions ?? {};
}
}
function sendProblem(req: Request, res: Response, problem: ProblemDetail) {
return res
.status(problem.status)
.type("application/problem+json")
.json({
...problem,
instance: problem.instance ?? req.originalUrl,
});
}
const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(2),
});
const app = express();
app.use(express.json());
app.post("/users", async (req, res, next) => {
try {
const result = createUserSchema.safeParse(req.body);
if (!result.success) {
throw new ApiError({
status: 422,
type: "https://api.example.com/problems/validation-error",
title: "Validation failed",
detail: "One or more fields failed validation.",
extensions: {
invalidParams: result.error.issues.map((issue) => ({
name: issue.path.join("."),
reason: issue.message,
})),
},
});
}
return res.status(201).json({
id: "user_123",
...result.data,
});
} catch (error) {
return next(error);
}
});
app.use((req, res) => {
return sendProblem(req, res, {
type: "https://api.example.com/problems/resource-not-found",
title: "Resource not found",
status: 404,
detail: "The requested resource could not be found.",
});
});
const problemDetailsHandler: ErrorRequestHandler = (err, req, res, next) => {
if (res.headersSent) {
return next(err);
}
if (err instanceof ApiError) {
return sendProblem(req, res, {
type: err.type,
title: err.title,
status: err.status,
detail: err.message,
...err.extensions,
});
}
return sendProblem(req, res, {
type: "https://api.example.com/problems/internal-server-error",
title: "Internal server error",
status: 500,
detail: "Something went wrong while processing the request.",
});
};
app.use(problemDetailsHandler);
app.listen(3000, () => {
console.log("API running on http://localhost:3000");
});
Test it with cURL:
curl -i \
-X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"email":"not-an-email","name":"A"}'
Expected response:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://api.example.com/problems/validation-error",
"title": "Validation failed",
"status": 422,
"detail": "One or more fields failed validation.",
"invalidParams": [
{
"name": "email",
"reason": "Invalid email address"
},
{
"name": "name",
"reason": "String must contain at least 2 character(s)"
}
],
"instance": "/users"
}
That is the core idea. Your controllers stay focused on business logic, and your API clients get one predictable error format.
FAQ
Is RFC 9457 only for Java or Spring Boot?
No. RFC 9457 is an HTTP API format, not a Java feature. Spring Boot has a ProblemDetail class, but Node.js and TypeScript APIs can return the same JSON structure with plain objects.
Should every error use application/problem+json?
For API errors, yes, it is a good default. Successful responses should keep their normal media type, usually application/json.
Should type always be a real URL?
It should be a URI. A URL is useful because developers can open it and read documentation about that error type. Some teams use about:blank for generic errors, but custom URLs are better for public APIs.
What is the difference between title and detail?
Use title for the general problem name, such as "Validation failed". Use detail for this specific request, such as "The email field must contain a valid email address."
Should validation errors use 400 or 422?
Use 400 Bad Request when the request is malformed, such as invalid JSON. Use 422 Unprocessable Content when the JSON is valid but the submitted values fail validation or business rules.
Can I add fields like traceId or errorCode?
Yes. RFC 9457 allows extension fields. Add fields that help clients or support teams, but keep them stable and documented.
Should I expose internal error messages?
No. Log internal errors on the server. Return a safe message to the client, plus a traceId if you want support teams to find the original error quickly.
Final check
RFC 9457 gives Node.js and TypeScript APIs a practical error response format that clients can actually use. Start with the standard fields, add custom fields only when they help, and keep the same shape across 400, 404, 422, 500, and other errors.
Before publishing or shipping your next API response, test a few real examples with the REST API Error Response Format Checker. It is a quick way to catch vague messages, missing fields, and inconsistent JSON before your API users find them.