Skip to content
enes
2026-03-21engineering3 min de lectura

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; el code doblea como el message del 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

Diagrama: una excepción tipada se lanza, el manejador global lee code, statusCode y report, y reparte la información a tres destinos: log estructurado, error tracker condicional, y respuesta HTTP con solo el código.

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.