Typed errors: each error with its code and its destination.
`throw new Error('not found')` tells you nothing in production. Each error is a type, with a stable code, and declares whether it deserves to reach the error tracker.
Something breaks in production. You open the logs and read:
Error: not found
You don't know if it's the user, the order, the file the external API was about to process, or the tenant that got deleted. The failed request left no structured trace.
throw new Error('not found') is the worst decision you can make in error handling. Every one of those strings is a missed chance to know what broke and how to respond.
Every error is a type
A base hierarchy TypeScript forces you to honor:
abstract class AppError extends Error {
abstract readonly code: string
abstract readonly statusCode: number
abstract readonly report: boolean
constructor(code: string, public readonly cause?: unknown) {
super(code)
this.name = this.constructor.name
}
}
Three fields do the work:
code, a stable, machine-readable identifier:AUTH_INVALID_CREDENTIALS,ORDER_NOT_FOUND,S3_UPLOAD_FAILED. The backend never emits human copy; thecodedoubles as the responsemessage.statusCode, the HTTP status that goes with it, so the global handler responds without thinking.report, the boolean that decides whether the exception becomes an alert in your error tracker (Sentry, Datadog, Rollbar, whatever you use).
Each subclass is one line:
class InvalidCredentialsError extends AppError {
readonly code = 'AUTH_INVALID_CREDENTIALS'
readonly statusCode = 401
readonly report = false // user-driven, not a bug
}
class S3UploadError extends AppError {
readonly code = 'S3_UPLOAD_FAILED'
readonly statusCode = 500
readonly report = true // operational, the tracker needs to see it
constructor(cause: unknown) { super('S3_UPLOAD_FAILED', cause) }
}
throw new InvalidCredentialsError() replaces the throw new Error('not found'). The global handler has three fields to make three distinct decisions.
The flow
One exception yields structured signal to three different consumers without writing routing logic in every catch.
Why that flag changes everything
Without that flag, you ship everything to your error tracker and the dashboard turns into noise. 90% of auth exceptions are users typing the wrong password, not bugs. Send them all and you lose signal.
With the flag, you distinguish:
false, user-driven error, expected inside the product's flow. The user got it wrong, the system worked. No one needs to wake up.true, operational error or bug. S3 down, a transaction that failed, an external API that returned an unexpected shape. Your tracker needs to see it.
That single decision, declared on every subclass, is what makes the dashboard useful again.
Backend emits codes, frontend translates
The HTTP response never carries human text. The client receives:
{ "statusCode": 401, "message": "AUTH_INVALID_CREDENTIALS" }
The frontend holds the only code → localized text table:
// errors.en.json
{ "AUTH_INVALID_CREDENTIALS": "Invalid credentials." }
That separation buys you two things:
- i18n for free, without the backend knowing about locales.
- Safe copy refactors, the frontend changes wording without touching the backend.
The rule
If your global handler catches Error, you don't know what to do with it. If it catches a specific class, you know exactly what to do.
catch (error: unknown) without narrowing is any with makeup on. A typed hierarchy gives you the narrowing for free.