DEV Community

Sreerag P
Sreerag P

Posted on

I Kept Writing the Same AppError Class. So I Stopped.

The class you've probably written five times

Open any Node backend you've built and grep for AppError, ApiError, or HttpError. There's a good chance you'll find one — hand-rolled, slightly different from the last one you wrote, and quietly incomplete.

You know the pattern:

class AppError extends Error {
  constructor(public code: string, public status: number, message: string) {
    super(message)
  }
}
Enter fullscreen mode Exit fullscreen mode

It works, until it doesn't. Prisma throws a PrismaClientKnownRequestError with a P2002 code. Zod throws a ZodError with an issues array. Some library throws a raw string. Now you're writing this, somewhere in a catch block, for the fifth project in a row:

if (err instanceof Prisma.PrismaClientKnownRequestError) {
  if (err.code === "P2002") return res.status(409).json({ error: "Duplicate" })
  if (err.code === "P2025") return res.status(404).json({ error: "Not found" })
}
if (err instanceof ZodError) {
  return res.status(400).json({ error: err.issues })
}
// ...and so on, forever
Enter fullscreen mode Exit fullscreen mode

Every project reinvents this. Every project gets it slightly wrong — a raw stack trace leaks into a JSON response, a database error message reaches the client verbatim, a new Prisma major version reshapes meta.target and every handler that read it breaks silently.

I've written variations of this class across different projects. Every time, I figured the next version would finally be the clean one. It usually wasn't — the same instanceof chain, the same status-code mapping, the same fallback case I forgot to handle until it hit production.

The actual shape of the problem

It's not really an error-handling problem. It's a combinatorics problem.

Think of it as N libraries throwing their own error shapes — Prisma, Zod, Axios, Mongoose — against M frameworks that need to turn those into HTTP responses — Express, Fastify, NestJS, Hono. It's a mental model, not a literal count (a Prisma mapping doesn't strictly need to know about your framework), but it captures the real cost: without a shared contract, the same translation logic gets rewritten at every boundary, by every team, indefinitely.

Erris is an attempt to give that translation a shared shape — normalize library errors into one contract, once, and let every framework consume that same contract, instead of each one inventing its own (the same idea Standard Schema applied to validation).

What it actually looks like

Erris is a small, dependency-free TypeScript library (MIT, on GitHub) built around three ideas: declare your failure modes explicitly, normalize anything that gets thrown into one predictable shape, and render that shape safely at the boundary. Core stays transport-neutral — it doesn't know about HTTP. The @erris/http package is what turns a normalized error into an RFC 9457 Problem Details response, so you're not inventing your own JSON error format to document.

1. Declare your errors as data, not scattered classes

import { defineErrors } from "@erris/core"

export const UserErrors = defineErrors("user", {
  NOT_FOUND: { message: "Requested user account was not found" },
  EMAIL_EXISTS: { message: "A user with this email address already exists" },
})

const err = UserErrors.NOT_FOUND({ cause: new Error("DB record missing") })

err.code    // "user.not_found" — typed as a literal, not a loose string
err.message // "Requested user account was not found"
err.cause   // the original Error, kept but never serialized by accident
Enter fullscreen mode Exit fullscreen mode

Every error gets a namespaced, immutable code. No two teams' NOT_FOUND collide, because yours is user.not_found and theirs is auth.not_found. The original cause — including whatever a database driver put in there — travels with the error but is non-enumerable, so it can't accidentally leak into a JSON.stringify() sent to a client.

Got multiple domains? Merge them with duplicate-key detection built in:

import { combineErrors } from "@erris/core"

export const AppErrors = combineErrors(UserErrors, AuthErrors)
Enter fullscreen mode Exit fullscreen mode

2. Normalize anything thrown, without throwing again

This is the part that actually kills the boilerplate. Instead of a chain of instanceof checks in every catch block, you configure normalization once:

import { createNormalizer } from "@erris/core"

const normalize = createNormalizer({
  fallback: SystemErrors.INTERNAL,
  adapters: [zodAdapter, prismaAdapter],
})

try {
  throw new Error("Database timeout")
} catch (caught) {
  const err = normalize(caught) // guaranteed ErrisError — never throws
}
Enter fullscreen mode Exit fullscreen mode

Whatever comes in — a ZodError, a PrismaClientKnownRequestError, a raw string, null, someone's custom class — comes out the other side as a well-formed ErrisError. No branch you forgot to write, no unhandled case that becomes a 500 with a leaked stack trace.

3. Render it safely, once, at the boundary

import { createHttpTransport } from "@erris/http"

export const renderHttp = createHttpTransport({
  errors: AppErrors,
  mappings: {
    "user.not_found": { status: 404, title: "User Not Found" },
    "user.email_exists": { status: 409, title: "Email Conflict" },
  },
  fallback: { status: 500, title: "Internal Server Error", code: "system.internal" },
})
Enter fullscreen mode Exit fullscreen mode

That produces a clean, standard response body — no custom shape for every team to relearn:

{
  "title": "Email Conflict",
  "status": 409,
  "detail": "A user account with this email address already exists.",
  "code": "user.email_exists"
}
Enter fullscreen mode Exit fullscreen mode

Putting it together

const normalize = createNormalizer({
  fallback: SystemErrors.INTERNAL,
  adapters: [zodAdapter, prismaAdapter],
})

const renderHttp = createHttpTransport({ errors: Errors, mappings: { /* ... */ } })

export function handleBoundary(caught: unknown) {
  const errisError = normalize(caught) // always an ErrisError
  return renderHttp(errisError)        // always a valid RFC 9457 response
}
Enter fullscreen mode Exit fullscreen mode

One function. Every value that reaches your error boundary — from your own code or from a dependency — goes through the same normalization path and comes out as a predictable, safe HTTP response, as long as your adapters and fallback cover it.

Where it stands right now

Erris ships as four packages: @erris/core (zero runtime dependencies), @erris/http for the RFC 9457 transport, and adapters for @erris/adapter-zod and @erris/adapter-prisma.

I want to be upfront about where this is: it's early. The adapter list is short on purpose — Prisma and Zod cover the highest-pain cases first, not every library everyone uses. Framework-specific integrations (Express, NestJS, Fastify, Hono) aren't published yet. This isn't "the new standard for error handling" — it's a focused attempt at a real, specific problem, and it needs to survive contact with real codebases before it earns any bigger claim than that.

If you've written your own AppError class more than once, I'd genuinely like to know if this replaces it for you — or where it falls short. That feedback is the actual roadmap.

Top comments (0)