All articles

Stop Throwing new Error(). It's Lying to Your Logs.

One distinction that sets your HTTP status, your log level, and who gets paged.

Petar IvanovPetar Ivanov
7 min read
On this page

A user types their email wrong.

Your API fires back 500 Internal Server Error.

A log line lands at error level, and someone on call gets paged over a typo.

Except nothing on your side broke. Someone fumbled a form, and your code can’t tell that apart from the database falling over, because both went out the same door:

TypeScript
throw new Error('something went wrong');

When every error looks the same, your app spends its life guessing.

Is this a 400 or a 500? Should the user see this message? Is this a shrug or a 3 AM page?

A bare Error can’t answer any of it, so let’s give it the one thing it’s missing.

Every error is either expected or a surprise

Domain errors: someone broke a rule you expected them to break. Bad input, a forbidden action, a withdrawal bigger than the balance. These aren’t bugs; they’re your app working exactly as designed. You saw them coming, so you can say something useful back.

System errors: something broke that you didn’t expect. The DB connection dropped, an upstream API timed out, you ran out of memory. The user can’t fix any of these, and a polite message won’t help.

That’s the entire distinction, and the moment your code knows which kind it’s holding, four things it used to guess at become automatic:

  • the HTTP status it returns
  • the message the user sees
  • the level it logs at
  • whether it wakes someone up

Domain errors versus system errors compared across HTTP status, log level, paging and user message


The class you extend is the classification

You only need to answer one question to tell the buckets apart: was this expected or not?

I encode the answer in the type itself.

One shared base carries the two things every error needs: a status and a stable code, and the two buckets are two abstract classes on top of it.

TypeScript
abstract class AppError extends Error {
  abstract readonly status: number // the HTTP status it maps to
  abstract readonly code: string   // a stable, machine-readable code

  constructor(message: string, options?: ErrorOptions) {
    super(message, options) // native Error.cause keeps the original around
    this.name = this.constructor.name
  }
}

// The two buckets, as types.
abstract class DomainError extends AppError {} // expected — message safe to show
abstract class SystemError extends AppError {} // unexpected — message stays server-side

Then the actual errors are tiny. A few domain ones, a couple of system ones, and you add more as you need them:

TypeScript
class ValidationError extends DomainError {
  readonly status = 400
  readonly code = 'VALIDATION_ERROR'
}

class InsufficientFundsError extends DomainError {
  readonly status = 422
  readonly code = 'INSUFFICIENT_FUNDS'
}

class DatabaseError extends SystemError {
  readonly status = 500
  readonly code = 'DATABASE_ERROR'
}

status and code live on the base on purpose. The code that handles these never casts anything or parses a string; it just reads the fields.

Why a class split and not just a check on status < 500?

Because status is an HTTP opinion, and these errors outlive HTTP.

A GraphQL resolver or a queue consumer has no status code to check, but it still has to answer the same question. Real message or generic? Warn or page?


One instanceof routes every error

Here's where it pays off. One piece of middleware handles everything, and the logic is short:

TypeScript
function errorHandler(err: unknown, req: Request, res: Response, next: NextFunction) {
  // Already streaming a response? We can't change the status now. Let Express close the connection.
  if (res.headersSent) return next(err)

  // Domain error: expected. Tell the user what happened, log it calm.
  if (err instanceof DomainError) {
    logger.warn(err.message, { code: err.code, path: req.path })
    return res.status(err.status).json({
      error: { code: err.code, message: err.message },
    })
  }

  // Everything else is a surprise: a system error we wrapped, or a bug we didn't.
  // Same treatment. Hide the details, log loud, return a 5xx.
  logger.error('Unhandled error', {
    message: err instanceof Error ? err.message : String(err),
    stack: err instanceof Error ? err.stack : undefined,
    path: req.path,
  })
  return res.status(err instanceof SystemError ? err.status : 500).json({
    error: { code: 'INTERNAL_ERROR', message: 'Something went wrong on our end.' },
  })
}

A domain error gets the real message and the right status. Everything else gets the same generic message, because the user can’t do anything about any of them.

A wrapped system error only adds its status: an ExternalServiceError can carry a 503 so the client knows to retry later, and an unwrapped bug stays a plain 500.

No if (err.message.includes('not found')) and no switch statement. One instanceof does the routing.

Flowchart routing an error on a single instanceof DomainError check


Domain errors never page anyone

Once errors are split, your logging and alerting rules basically write themselves:

ErrorStatusLogPages?User sees
ValidationError400warnnothe real message
NotFoundError404warnnothe real message
InsufficientFundsError422warnnothe real message
DatabaseError500erroryes"Something went wrong"
ExternalServiceError503erroryes"Something went wrong"
Anything uncaught (a bug)500erroryes"Something went wrong"

Domain errors at warn keep your dashboards honest.

You’re not paging an engineer because someone typed a bad email a thousand times, and when an error-level line does show up, something is actually broken, so the alert is worth trusting.


Wrap system errors at the edges

Quick rule of thumb.

Domain errors come from your service layer and system errors get born at the edges, where your infrastructure wraps them.

So a raw driver error never leaks up the stack as-is.

TypeScript
class PostgresUserRepo {
  async findById(id: string): Promise<User | null> {
    try {
      return await this.db.oneOrNone('SELECT * FROM users WHERE id = $1', [id])
    } catch (err) {
      // Wrap it once, here, so the rest of the app only ever sees a DatabaseError.
      throw new DatabaseError(`findById failed for ${id}`, { cause: err })
    }
  }
}

The cause keeps the original error for your logs, but it never travels to the client.

Your domain code stops thinking about Postgres, and your Postgres code stops making business decisions.

Watch the names, though. “Not found” sounds like one error, but it’s two.

A user requesting /users/123 that doesn’t exist is domain; say so, return a 404. Your own code failing to load an ID it wrote five minutes ago is system, because that should be impossible.

Which base you extend comes from what you expected, not from what the error is called.


Two things that’ll bite you

  1. Domain messages go straight to the user, so keep secrets out of them.
  2. The whole thing rides on instanceof, which only works inside one copy of one module.

📌 TL;DR

  • Every error is domain (expected: bad input, broken rule) or system (unexpected: DB down, timeout).
  • That one distinction decides HTTP status, user message, log level, and whether anyone gets paged.
  • Encode it in the type: DomainError and SystemError
  • One handler routes everything: domain → real message + right status, logged calm. Everything else → generic message + 5xx, logged loud.
  • Domain messages reach the client, so keep secrets and internal numbers out of them.
  • Wrap raw errors at the edges (repos, API clients) so a system error is born as a DatabaseError, not a leaked driver stack trace.

Related articles

Whenever you’re ready, here’s how I can help you:

  1. 1.

    The Conscious React: React architecture, design & clean code — 100+ production tips across 6 chapters, updated for React 19, plus 4 companion repos you can clone and run.

  2. 2.

    The Conscious Node: Node.js architecture, design & clean code — 157 production tips across 10 chapters, from module boundaries to the transactional outbox and zero-downtime deploys.

  3. 3.

    The JavaScript Architect Bundle: Both books + all React companion repos + CLAUDE.md rulesets + both playbooks. The complete path from developer to architect.

  4. 4.

    Free Resources: Architecture playbooks, cheat-sheets, and the JavaScript Architect Roadmap — practical guides for leveling up to senior.

The T-Shaped Dev

Join 30K+ engineers leveling up to architect

One practical tip on JavaScript, React, Node.js, and software architecture every week. No spam, unsubscribe anytime.

Petar Ivanov

Written by

Petar Ivanov

Software engineer, author, and speaker. I help JavaScript developers grow from Mid → Senior → Architect — production-grade React, Node.js, and AI systems.