So I was working on this side project, and I realized something embarrassing: my API was basically broadcasting my entire database structure to anyone who sent a malformed request.
You know that moment when you're testing your endpoint and you get back something like this?
{
"error": "PrismaClientKnownRequestError: \nInvalid `prisma.user.create()` invocation:\n\n{\n data: {\n email: \"user@example.com\",\n ~~~~~~~~~~~~~~~~~~\n name: \"User\"\n }\n}\n\nUnique constraint failed on the fields: (`email`)"
}
That's not just ugly—it's a security nightmare. It is basically handing over your database blueprint to anyone who hits your endpoints.
What I really wanted was something clean like this:
{
"status": "fail",
"message": "Duplicate value found for email. Please use a different value."
}
Much better, right?
The Usual Tutorial Problem
Here's the thing about most Express tutorials: they show you the happy path. But real apps? They're chaos. Users send garbage data, your database goes down, network requests fail.
Without proper error handling, you're basically coding blindfolded.
My Solution: Error Translation Layer
Instead of trying to catch every possible error, I built a translation layer. Think of it as a bouncer for your errors—it takes the messy backend stuff and turns it into something your frontend can actually use.
Let me show you how I did it.
Setting Up the Project
First, we need a project that can actually break in interesting ways. I'm using Express with Prisma and SQLite because they generate the kind of detailed errors that make this demonstration worthwhile.
mkdir global-error-handling
cd global-error-handling
npm init -y
Install the essentials:
# Core dependencies
npm install express cors helmet dotenv @prisma/client
# Development tools
npm install -D typescript @types/express @types/node nodemon ts-node prisma
TypeScript Configuration
Create tsconfig.json
:
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "commonjs",
"rootDir": "./src",
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Package.json Scripts
Update your package.json
scripts section:
{
"scripts": {
"dev": "nodemon src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:studio": "prisma studio"
}
}
Database Setup with Prisma
Initialize Prisma:
npx prisma init --datasource-provider sqlite
Create your database schema in prisma/schema.prisma
:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Generate the Prisma client and push the schema:
npm run db:generate
npm run db:push
Building the Error Handling System
Now, let's build our error handling system. Create the src
directory and start with our utility classes.
Creating a Custom Error Class
// src/utils/appError.ts
class AppError extends Error {
statusCode: number;
status: "fail" | "error";
isOperational: boolean;
constructor(message: string, statusCode: number) {
super(message);
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith("4")
? "fail"
: "error";
this.isOperational = true;
this.message = message;
Error.captureStackTrace(this, this.constructor);
}
}
export default AppError;
Eliminating Try-Catch Hell
// src/utils/catchAsync.ts
import { Request, Response, NextFunction } from 'express';
const catchAsync = (
fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>,
) => {
return (req: Request, res: Response, next: NextFunction) => {
fn(req, res, next).catch((err) => next(err));
};
};
export default catchAsync;
The Main Event: Global Error Handler
This is where the magic happens. Express lets you define a global error handler that catches everything:
// src/middleware/errorHandler.ts
import { NextFunction, Request, Response } from "express";
import {
PrismaClientKnownRequestError,
PrismaClientValidationError,
PrismaClientInitializationError,
PrismaClientRustPanicError,
} from "@prisma/client/runtime/library";
import AppError from "../utils/appError";
interface ErrorWithCode extends Error {
code?: string;
statusCode?: number;
status?: string;
isOperational?: boolean;
}
const handlePrismaValidationError = (
err: PrismaClientValidationError
): AppError => {
return new AppError(
"Invalid input data. Please check your request.",
400
);
};
const handlePrismaKnownError = (
err: PrismaClientKnownRequestError
): AppError => {
const errorMap: Record<string, () => AppError> = {
P2002: () => {
const field =
(err.meta?.target as string[])?.join(", ") ||
"field";
return new AppError(
`Duplicate value found for ${field}. Please use a different value.`,
409
);
},
P2003: () => {
const field =
(err.meta?.field_name as string) ||
(err.meta?.target as string) ||
"unknown field";
return new AppError(
`Invalid reference for field: ${field}`,
400
);
},
P2025: () => new AppError("Record not found.", 404),
P2021: () =>
new AppError("The table does not exist.", 500),
P2022: () =>
new AppError("The column does not exist.", 500),
};
return (
errorMap[err.code]?.() ||
new AppError("Database error occurred.", 500)
);
};
const handlePrismaInitializationError = (
err: PrismaClientInitializationError
): AppError => {
return new AppError(
"Database connection failed. Please try again later.",
500
);
};
const handlePrismaRustPanicError = (
err: PrismaClientRustPanicError
): AppError => {
return new AppError(
"Critical database error occurred. Please try again.",
500
);
};
// Development error response
const sendErrorDev = (
err: ErrorWithCode,
res: Response
): void => {
res.status(err.statusCode || 500).json({
status: err.status,
message: err.message,
error: err,
stack: err.stack,
});
};
// Production error response
const sendErrorProd = (
err: ErrorWithCode,
res: Response
): void => {
if (err.isOperational) {
res.status(err.statusCode || 500).json({
status: err.status,
message: err.message,
});
} else {
res.status(500).json({
status: "error",
message: "Something went wrong!",
});
}
};
// Global error handling middleware
const globalErrorHandler = (
err: ErrorWithCode,
req: Request,
res: Response,
next: NextFunction
): void => {
err.statusCode = err.statusCode || 500;
err.status = err.status || "error";
const isDev = process.env.NODE_ENV === "development";
let error = err;
if (err instanceof PrismaClientValidationError) {
error = handlePrismaValidationError(err);
} else if (err instanceof PrismaClientKnownRequestError) {
error = handlePrismaKnownError(err);
} else if (
err instanceof PrismaClientInitializationError
) {
error = handlePrismaInitializationError(err);
} else if (err instanceof PrismaClientRustPanicError) {
error = handlePrismaRustPanicError(err);
}
if (isDev) {
sendErrorDev(error, res);
} else {
sendErrorProd(error, res);
}
};
export default globalErrorHandler;
The key insight here is the two-environment approach. During development, you want to see everything—stack traces, error objects, the full disaster. In production, you want clean, professional responses that don't leak implementation details.
Building Routes That Actually Break
Let's create some routes that demonstrate different error scenarios:
// src/routes/users.ts
import { Router, Request, Response } from "express";
import { PrismaClient } from "@prisma/client";
import AppError from "../utils/appError";
import catchAsync from "../utils/catchAsync";
const router = Router();
const prisma = new PrismaClient();
router.get(
"/:id",
catchAsync(async (req: Request, res: Response) => {
const { id } = req.params;
const user = await prisma.user.findUnique({
where: { id: parseInt(id) },
include: {
posts: true,
},
});
if (!user) {
throw new AppError("User not found", 404);
}
res.status(200).json({
status: "success",
data: { user },
});
})
);
router.post(
"/",
catchAsync(async (req: Request, res: Response) => {
const { email, name } = req.body;
if (!email) {
throw new AppError("Email is required", 400);
}
// This will trigger Prisma P2002 error if email already exists
const user = await prisma.user.create({
data: {
email,
name,
},
});
res.status(201).json({
status: "success",
data: { user },
});
})
);
router.post(
"/:id/posts",
catchAsync(async (req: Request, res: Response) => {
const { id } = req.params;
const { title, content } = req.body;
if (!title) {
throw new AppError("Title is required", 400);
}
// This will trigger Prisma P2003 error if user doesn't exist
const post = await prisma.post.create({
data: {
title,
content,
authorId: parseInt(id),
},
});
res.status(201).json({
status: "success",
data: { post },
});
})
);
router.delete(
"/:id",
catchAsync(async (req: Request, res: Response) => {
const { id } = req.params;
// This will trigger Prisma P2025 error if user doesn't exist
await prisma.user.delete({
where: { id: parseInt(id) },
});
res.status(204).json({
status: "success",
data: null,
});
})
);
export default router;
Putting It All Together
// src/app.ts
import express from "express";
import cors from "cors";
import helmet from "helmet";
import AppError from "./utils/appError";
import globalErrorHandler from "./middleware/errorHandler";
import userRoutes from "./routes/users";
const app = express();
// Basic middleware
app.use(helmet());
app.use(cors());
app.use(express.json({ limit: "10kb" }));
// API routes
app.use("/api/users", userRoutes);
// Catch undefined routes
app.all("*", (req, res, next) => {
next(
new AppError(
`Can't find ${req.originalUrl} on this server`,
404
)
);
});
// Global error handler (must be last!)
app.use(globalErrorHandler);
export default app;
Server Setup with Process Handling
// src/server.ts
import { PrismaClient } from "@prisma/client";
import dotenv from "dotenv";
import app from "./app";
dotenv.config();
const prisma = new PrismaClient();
const port = process.env.PORT || 3000;
/**
* Handle uncaught exceptions
* These are synchronous errors that occur outside of Express
*/
process.on("uncaughtException", (err: Error) => {
console.log("💥 UNCAUGHT EXCEPTION! Shutting down...");
console.log(err.name, err.message);
process.exit(1);
});
/**
* Start the server
*/
async function startServer() {
try {
// Connect to database
await prisma.$connect();
console.log("Database connected successfully");
// Start Express server
const server = app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
/**
* Handle unhandled promise rejections
* These are async errors that aren't caught anywhere
*/
process.on("unhandledRejection", (err: Error) => {
console.log(
"💥 UNHANDLED REJECTION! Shutting down..."
);
console.log(err.name, err.message);
server.close(() => {
process.exit(1);
});
});
/**
* Graceful shutdown on SIGTERM
*/
process.on("SIGTERM", async () => {
console.log(
"SIGTERM received. Shutting down gracefully..."
);
await prisma.$disconnect();
server.close(() => {
console.log("Process terminated");
});
});
} catch (error) {
console.error("Failed to start server:", error);
await prisma.$disconnect();
process.exit(1);
}
}
startServer();
Running the Application
- Create a
.env
file:
NODE_ENV=development
PORT=3000
- Start the development server:
npm run dev
Testing The System
To test our error handling system, create an api.http
file in your project root. This file works with the REST Client extension in VS Code, making it easy to test your API endpoints.
Create api.http
:
## 1. SUCCESS CASES (To Setup Test Data)
### Create first user (SUCCESS)
POST http://localhost:3000/api/users
Content-Type: application/json
{
"email": "user1@example.com",
"name": "user1"
}
## 2. PRISMA ERROR TESTING
### P2002 - Unique Constraint Violation (Duplicate Email)
### This will trigger the global error handler for duplicate emails
POST http://localhost:3000/api/users
Content-Type: application/json
{
"email": "user1@example.com",
"name": "Another user1"
}
### P2003 - Foreign Key Constraint Violation (Invalid User ID)
### This will trigger the global error handler for invalid foreign key
POST http://localhost:3000/api/users/999/posts
Content-Type: application/json
{
"title": "Post with Invalid User",
"content": "This should fail because user 999 doesn't exist"
}
### P2025 - Record Not Found (Delete Non-existent User)
### This will trigger the global error handler for record not found
DELETE http://localhost:3000/api/users/999
## 3. VALIDATION ERRORS
### Missing required email field
POST http://localhost:3000/api/users
Content-Type: application/json
{
"name": "User Without Email"
}
### Missing required title field for post
POST http://localhost:3000/api/users/1/posts
Content-Type: application/json
{
"content": "Post without title"
}
### 4. MALFORMED REQUEST ERRORS
### Invalid JSON (will trigger Express JSON parser error)
POST http://localhost:3000/api/users
Content-Type: application/json
{
"email": "test@example.com"
"name": "Invalid JSON" // Missing comma
}
To test both environments:
- Run tests with
NODE_ENV=development
(default) - Change to
NODE_ENV=production
in your.env
file and restart the server - Run the same tests to see the difference in error responses
What This Gets You
With this setup, you get:
- Consistent error responses across your entire API
- User-friendly messages instead of database internals
- Appropriate detail levels for dev vs production
- Clean route handlers without try-catch hell
- Proper HTTP status codes for different error types
The best part? Once you set this up, you barely think about it. Your routes stay clean, your users get helpful error messages, and you sleep better knowing your database schema isn't being broadcast to the internet.
Top comments (0)