Errores tipados: cada error con su código y su destino.
`throw new Error('not found')` no te dice nada en producción. Cada error es un tipo, con un código estable, y declara si merece llegar al error tracker.
Algo se rompe en producción. Abrís los logs y leés:
Error: not found
No sabés si es el usuario, el order, el archivo que la API externa iba a procesar, o el tenant que dejó de existir. La request que falló no dejó rastro estructurado.
throw new Error('not found') es la peor decisión que podés tomar en error handling. Cada uno de esos strings es una oportunidad perdida de saber qué se rompió y cómo responder.
Cada error es un tipo
Una jerarquía base que TypeScript te obliga a respetar:
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
}
}
Tres campos hacen el trabajo:
code, un identificador estable y machine-readable:AUTH_INVALID_CREDENTIALS,ORDER_NOT_FOUND,S3_UPLOAD_FAILED. El backend nunca emite copy humano; elcodedoblea como elmessagedel response.statusCode, el HTTP status que corresponde, así el handler global responde sin pensar.report, el booleano que decide si la excepción se convierte en alerta o no en tu error tracker (Sentry, Datadog, Rollbar, lo que uses).
Cada subclase es una línea:
class InvalidCredentialsError extends AppError {
readonly code = 'AUTH_INVALID_CREDENTIALS'
readonly statusCode = 401
readonly report = false // user-driven, no es bug
}
class S3UploadError extends AppError {
readonly code = 'S3_UPLOAD_FAILED'
readonly statusCode = 500
readonly report = true // operacional, hay que verlo
constructor(cause: unknown) { super('S3_UPLOAD_FAILED', cause) }
}
throw new InvalidCredentialsError() reemplaza al throw new Error('not found'). El handler global tiene tres campos para tomar tres decisiones distintas.
El flujo
Una sola excepción produce señal estructurada para tres consumidores distintos sin escribir lógica de routing en cada catch.
Por qué ese flag cambia todo
Sin ese flag, mandás todo al error tracker y el dashboard se vuelve ruido. El 90% de las excepciones de auth son usuarios poniendo mal la contraseña, no bugs. Si las mandás todas, perdés señal.
Con el flag, distinguís:
false, error user-driven, esperado dentro del flow del producto. El usuario se equivocó, el sistema funcionó. No hace falta despertar a nadie.true, error operacional o de bug. S3 caído, una transacción que falló, una API externa que devolvió forma inesperada. Tu tracker tiene que verlo.
Esa única decisión, declarada en cada subclase, es lo que hace que el dashboard vuelva a ser útil.
Backend emite códigos, frontend traduce
El response HTTP nunca lleva texto humano. El cliente recibe:
{ "statusCode": 401, "message": "AUTH_INVALID_CREDENTIALS" }
El frontend tiene la única tabla code → texto localizado:
// errors.es.json
{ "AUTH_INVALID_CREDENTIALS": "Credenciales inválidas." }
Compras dos cosas con esa separación:
- i18n por default, sin que el backend conozca locales.
- Refactor seguro de copy, el frontend cambia el texto sin tocar el backend.
La regla
Si tu handler global captura Error, no sabés qué hacer con él. Si captura una clase específica, sabés exactamente qué hacer.
catch (error: unknown) sin narrowing es un any con maquillaje. Una jerarquía tipada te devuelve el narrowing gratis.