Best Way To Structure Your Api Errors

In the previous blog, we discussed how to structure our API responses. In this article, we’ll take the same approach for API errors. If you haven’t read the previous blog, I recommend you check it out first as this article builds upon it. You can find it here.

Why Error Handling Matters

Errors are inevitable in backend architecture. They can occur due to user mistakes (e.g., requesting a non-existent user profile) or internal issues (e.g., a database crash). Designing a consistent and informative error structure is essential for debugging and improving the developer experience.

Some Errors Are

Status CodeDescription
400 Bad RequestUser missed a required field or sent invalid data
401 UnauthorizedUser lacks necessary permissions
404 Not FoundRequested resource does not exist
409 ConflictUser tried to register with an already existing email
500 Internal Server ErrorA server-side failure (likely a bug in the code)

Basic Error Handling in Practice

In the previous article, our controller looked like this:

const mongoose = require("mongoose");
const userModel = require("../models/user.model.js");
const { ApiResponse } = require("../utils/ApiResponse.js");

export const profileController = async (req, res) => {
    try {
        const { id } = req.params;

        if (!id.trim() || !mongoose.Types.ObjectId.isValid(id)) {
            return res.status(400).json(new ApiResponse(400, "Invalid user ID", [], ["Invalid parameter"]));
        }

        const user = await userModel.findById(id).select("-password");

        if (!user) {
            return res.status(404).json(new ApiResponse(404, "User not found", [], ["No user with given ID"]));
        }

        return res.status(200).json(new ApiResponse(200, "User data fetched successfully", user));
    } catch (error) {
        console.error(error); // Log the error properly in production
        return res.status(500).json(new ApiResponse(500, "Internal Server Error", [], [error.message]));
    }
}

If the user doesn’t exist, the response will look like this:

{
    "status": 404,
    "success": false,
    "message": "User not found",
    "data": [],
    "errors": ["No user with given ID"]
}

Improving the Codebase with ApiError

To streamline and standardize error handling, we can introduce an ApiError class.

This class inherits from Node’s built-in Error class and encapsulates status code, message, and an array of error details. The success field is set to false by default.

ApiError Class Implementation

export class ApiError extends Error {

    constructor(status, message, errors=[]) {

        super(message);
        this.status = status;
        this.success = false;
        this.message = message;
        this.data = null;
        this.errors = errors;
    }
}

Updated Controller Using ApiError

Here’s how the controller looks after implementing the ApiError class:

const mongoose = require("mongoose");
const userModel = require("../models/user.model.js");
const { ApiResponse } = require("../utils/ApiResponse.js");
const { ApiError } = require("../utils/ApiError.js");

export const profileController = async (req, res) => {
    try {
        const { id } = req.params;

        if (!id.trim() || !mongoose.Types.ObjectId.isValid(id)) {
            return res.status(400).json(new ApiError(400, "Invalid user ID", ["Invalid parameter"]));
        }

        const user = await userModel.findById(id).select("-password");

        if (!user) {
            return res.status(404).json(new ApiError(404, "User not found", ["No user with given ID"]));
        }

        return res.status(200).json(new ApiResponse(200, "User data fetched successfully", user));
    } catch (error) {
        console.error(error); // Log the error properly in production
        return res.status(500).json(new ApiError(500, "Internal Server Error", [error.message]));
    }
}

Right now, Both ApiResponse and ApiError provides same functionality, But we will change it slightly by defining static functions in ApiError class. This static functions will provide you some basic functionality.

Start with Not Found, We will define a NotFound method in ApiError.

export class ApiError extends Error {

    constructor(status, message, errors=[]) {

        super(message);
        this.status = status;
        this.success = false;
        this.message = message;
        this.data = null;
        this.errors = errors;
    }

    // Not Founc
    static NotFound(errors=[]){
        return new ApiError(404, "Not Found", errors);
    }
}

This will prevent your basic typos in the error messages in it helps you to throw errors like easy.

Now define methods for each error,

export class ApiError extends Error {

    constructor(status, message, errors=[]) {

        super(message);
        this.status = status;
        this.success = false;
        this.message = message;
        this.data = null;
        this.errors = errors;
    }

    // Not Founc
    static NotFound(errors=[]){
        return new ApiError(404, "Not Found", errors);
    }

    // Unauthorized
    static Unauthorized(errors=[]){
        return new ApiError(401, "Unauthorized", errors);
    }

    // Bad Request
    static BadRequest(errors=[]){
        return new ApiError(400, "Bad Request", errors);
    }

    // Conflict
    static Conflict(errors=[]){
        return new ApiError(409, "Conflict", errors);
    }

    // Internal Server Error
    static InternalServerError(errors=[]){
        return new ApiError(500, "Internal Server Error", errors);
    }
}

This is your updated controller now.

const mongoose = require("mongoose");
const userModel = require("../models/user.model.js");
const { ApiResponse } = require("../utils/ApiResponse.js");
const { ApiError } = require("../utils/ApiError.js");

export const profileController = async (req, res) => {
    try {
        const { id } = req.params;

        if (!id.trim() || !mongoose.Types.ObjectId.isValid(id)) {
            return res.status(400).json(ApiError().BadRequest(["Invalid parameter"]));
        }

        const user = await userModel.findById(id).select("-password");

        if (!user) {
            return res.status(404).json(ApiError().NotFound(["No user with given ID"]));
        }

        return res.status(200).json(new ApiResponse(200, "User data fetched successfully", user));
    } catch (error) {
        console.error(error); // Log the error properly in production
        return res.status(500).json(ApiError().InternalServerError([error.message]));
    }
}

Conclusion

By introducing an ApiError class and using static factory methods like BadRequest, NotFound, Conflict, etc., your code becomes consistent, maintainable and clean.

This structure will save you hours of debugging and rewriting as your controller count grows from 5 to 50+. More importantly, it brings predictability to your API behaviour for frontend developers, third-party consumers, and testers.

Implement this error structure early in your project lifecycle — your future self and your team will thank you.

10
Subscribe to my newsletter

Read articles from Ismail Bin Mujeeb directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ismail Bin Mujeeb
Ismail Bin Mujeeb