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:
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.
Share this post & I’ll send you some rewards for the referrals.
Get into the flow of work with Miro Flows (Partner)
Miro is the collaborative workspace where agents and teams converge to think, decide, and build together.
(Thanks to Miro for partnering on this post.)
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
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.
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-sideThen the actual errors are tiny. A few domain ones, a couple of system ones, and you add more as you need them:
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:
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.
Domain errors never page anyone
Once errors are split, your logging and alerting rules basically write themselves:
Error Status Logs at Pages someone? User sees
ValidationError 400 warn no the real message
NotFoundError 404 warn no the real message
InsufficientFundsError 422 warn no the real message
DatabaseError 500 error yes "Something went wrong"
ExternalServiceError 503 error yes "Something went wrong"
Anything uncaught (a bug) 500 error yes "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.
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
Domain messages go straight to the user, so keep secrets out of them.
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:
DomainErrorandSystemErrorOne 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.
Follow me on LinkedIn | Twitter(X) | Threads
Thank you for supporting this newsletter.
Consider sharing this post with your friends and get rewards.
You are the best! 🙏







Good stuff! 👍🏻