Hay un patrón que aparece en casi todos los proyectos con bugs difíciles de rastrear: en algún punto, alguien envolvió código en un try/catch para que dejara de "romper la app", y en el catch no dejó nada. El error desaparece de la consola, la aplicación sigue funcionando en apariencia, y tres semanas después alguien pierde una tarde entera intentando entender por qué un dato llega vacío sin ningún error que lo explique.

El try/catch no es una herramienta para hacer que los errores desaparezcan. Es una herramienta para decidir qué hacer cuando algo falla, y "no hacer nada" casi nunca es esa decisión.

Cómo funciona try/catch (lo básico, rápido)

El bloque try ejecuta código que puede fallar; si lanza una excepción, la ejecución salta inmediatamente al bloque catch, que recibe el error como parámetro. El bloque opcional finally se ejecuta siempre, falle o no falle el try — útil para liberar recursos, cerrar conexiones o parar un spinner de carga.

javascript
try {
  const datos = JSON.parse(respuestaCruda);
  procesar(datos);
} catch (error) {
  console.error("Error al parsear la respuesta:", error);
} finally {
  ocultarSpinner();
}

El error más común: silenciar en vez de manejar

Comparar un catch vacío con uno que realmente hace algo es la mejor forma de ver la diferencia. Aquí tienes el mismo problema resuelto de dos formas — solo una de ellas te va a ahorrar tiempo cuando algo falle en producción.

javascript
async function obtenerUsuario(id) {
  try {
    const res = await fetch(`/api/usuarios/${id}`);
    const usuario = await res.json();
    return usuario;
  } catch (error) {
    // se traga el error, la app "no se rompe"
    // pero nadie se entera de que algo falló
  }
}

// más adelante...
const usuario = await obtenerUsuario(42);
console.log(usuario.nombre); // TypeError: usuario is undefined
javascript
async function obtenerUsuario(id) {
  try {
    const res = await fetch(`/api/usuarios/${id}`);
    if (!res.ok) {
      throw new Error(`Respuesta ${res.status} al pedir el usuario ${id}`);
    }
    return await res.json();
  } catch (error) {
    console.error(`No se pudo obtener el usuario ${id}:`, error);
    // decides explícitamente qué pasa: relanzar, devolver un
    // valor por defecto seguro, o notificar a quien llamó
    throw error;
  }
}

// más adelante...
try {
  const usuario = await obtenerUsuario(42);
  console.log(usuario.nombre);
} catch {
  mostrarMensajeError("No se pudo cargar el usuario. Inténtalo de nuevo.");
}

La diferencia no es solo estética. En la versión mala, fetch ni siquiera lanza error cuando el servidor responde con un 404 o un 500 (solo lanza si la petición de red falla de raíz), así que el try/catch ni se entera de que algo fue mal. En la versión buena, se comprueba res.ok explícitamente, se registra el error con contexto (qué usuario, qué código de estado) y se decide de forma consciente qué hacer después: relanzar para que quien llamó a la función pueda reaccionar.

Relanzar vs gestionar: cuándo hacer cada cosa

No siempre hay que relanzar el error. La regla práctica es: si la función que captura el error tiene contexto suficiente para decidir qué hacer (mostrar un mensaje, usar un valor por defecto, reintentar), gestiónalo ahí. Si no lo tiene —por ejemplo, una función de utilidad genérica que no sabe si el resultado se va a usar para pintar una UI o para un proceso en segundo plano— relánzalo y deja que decida quien esté más arriba en la pila de llamadas.

Errores personalizados: cuando "Error" genérico no basta

El objeto Error nativo de JavaScript sirve para casi todo, pero cuando necesitas distinguir tipos de fallos —un error de validación no se gestiona igual que un error de red— conviene crear tus propias clases de error extendiendo Error.

javascript
class ValidationError extends Error {
  constructor(mensaje, campo) {
    super(mensaje);
    this.name = "ValidationError";
    this.campo = campo;
  }
}

function crearUsuario(datos) {
  if (!datos.email || !datos.email.includes("@")) {
    throw new ValidationError("Email no válido", "email");
  }
  if (!datos.password || datos.password.length < 8) {
    throw new ValidationError("La contraseña debe tener al menos 8 caracteres", "password");
  }
  return { id: crypto.randomUUID(), ...datos };
}

try {
  crearUsuario({ email: "no-es-un-email", password: "1234" });
} catch (error) {
  if (error instanceof ValidationError) {
    // sabes exactamente qué campo falló y puedes mostrarlo en el formulario
    mostrarErrorEnCampo(error.campo, error.message);
  } else {
    // cualquier otro error inesperado, no lo trates como validación
    console.error("Error inesperado:", error);
    throw error;
  }
}

Con instanceof puedes distinguir un error de validación de cualquier otro fallo inesperado, y reaccionar de forma distinta a cada uno. Esto es especialmente útil en APIs REST, donde un error de validación debería devolver un 400 con un mensaje claro, mientras que un error inesperado del servidor debería devolver un 500 sin exponer detalles internos al cliente.

Errores asíncronos que se escapan del try/catch

Un fallo habitual: un try/catch no captura errores lanzados dentro de un setTimeout, una promesa sin await, o un callback asíncrono que se ejecuta después de que el bloque ya haya terminado. Si no usas async/await, necesitas manejar el error donde realmente ocurre, o encadenar .catch() en la promesa correspondiente.

javascript
// Esto NO captura el error: el try ya terminó cuando el timeout se ejecuta
try {
  setTimeout(() => {
    throw new Error("esto escapa del catch");
  }, 1000);
} catch (error) {
  console.error(error); // nunca se ejecuta
}

// Correcto: maneja el error dentro del propio callback
setTimeout(() => {
  try {
    operacionArriesgada();
  } catch (error) {
    console.error("Error en la tarea diferida:", error);
  }
}, 1000);

Este matiz es el mismo motivo por el que un buen conjunto de tests con Jest es tan útil: un test que fuerza el caso de error explícitamente descubre estos huecos antes de que lleguen a producción, algo que revisar el código a simple vista rara vez detecta.

Reglas rápidas para no silenciar errores

Aplicar estas reglas de forma consistente es, en el fondo, la misma idea que recorre cualquier práctica de código limpio: hacer que el comportamiento del programa sea predecible y fácil de razonar, también cuando las cosas van mal.

JR

Javier Rodríguez Fernández

Programador con Grado Superior en Desarrollo de Aplicaciones Multiplataforma (DAM). Escribe en DevPulso sobre programación aplicada, sin relleno.