La firma de tu función dice que devuelve un User. No es así. Devuelve un User o explota. El sistema de tipos simplemente no sabe nada de la segunda branch.
Esta es la deshonestidad fundamental del manejo de errores basado en excepciones. Cada throw es un camino de flujo de control que tu compiler no puede ver, no puede verificar y no puede exigir. Terminas con código que pasa el type check perfectamente y aun así se cae en producción porque alguien olvidó un try/catch tres capas de llamadas más arriba.
Los retornos de error explícitos arreglan esto. Hacen que el fallo sea un ciudadano de primera clase de tu sistema de tipos. La pregunta es si el costo ergonómico vale la honestidad.
Qué Significa la Corrección a Nivel de Tipos para los Errores
La corrección a nivel de tipos significa que tus tipos dicen la verdad sobre lo que una función puede producir. Si una función puede fallar, el tipo de retorno debería decirlo. Si no puede, el tipo de retorno debería garantizar el éxito.
Rust codifica esto con Result<T, E>:
fn parse_port(s: &str) -> Result<u16, ParseIntError> {
s.parse()
}
La firma es completa. Quienes la llaman saben que hay dos resultados posibles. El compiler los obliga a manejar ambos.
TypeScript, por defecto, no lo hace. Una función que lanza una excepción se ve idéntica a una que no:
function parsePort(s: string): number {
const n = parseInt(s, 10);
if (isNaN(n) || n < 0 || n > 65535) {
throw new Error(`Invalid port: ${s}`);
}
return n;
}
El tipo de retorno dice number. Debería decir number | Error, o más exactamente, Result<number, Error>. Pero no lo hace, así que quienes llaman la función no tienen ninguna señal de que necesitan defenderse contra el fallo.
Cómo los Errores Lanzados se Esconden en tu Grafo de Llamadas
El daño se acumula a medida que tu call stack se profundiza. Un error lanzado en una función hoja obliga a cada capa intermedia a atraparlo o propagarlo implícitamente. Ninguno de esos caminos de propagación aparece en ninguna firma de tipo.
Considera un config loader que llama a un parser que llama a un validator. Si el validator lanza una excepción, el parser y el loader también se vuelven lanzables. Pero sus tipos no cambian. No puedes mirar loadConfig() y saber que puede fallar. Tienes que leer cada línea de cada dependencia, o esperar a un crash en runtime.
Esto es exactamente lo contrario a cómo se supone que funcionan los tipos. Los tipos existen para que no tengas que leer la implementación para entender el contrato.
Los retornos de error explícitos invierten esto. El camino de fallo es visible en cada capa:
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
function parsePort(s: string): Result<number, string> {
const n = parseInt(s, 10);
if (isNaN(n) || n < 0 || n > 65535) {
return { ok: false, error: `Invalid port: ${s}` };
}
return { ok: true, value: n };
}
function loadConfig(raw: Record<string, string>): Result<Config, string> {
const portResult = parsePort(raw.port);
if (!portResult.ok) return portResult;
const host = raw.host || "localhost";
return { ok: true, value: { port: portResult.value, host } };
}
Ahora la firma de loadConfig cuenta toda la historia. Sabes que puede fallar antes de leer una sola línea del cuerpo.
El Verdadero Compromiso es la Ergonomía, No la Expresividad
El enfoque honesto es más verboso. A nadie le gusta escribir { ok: false, error: ... } en lugar de throw new Error(...). El ruido visual se acumula, especialmente cuando encadenas múltiples operaciones falibles.
Por eso la mayoría de los lenguajes con checked exceptions las abandonaron. Las checked exceptions de Java intentaron exigir el manejo explícito a nivel de tipos, pero la sintaxis era punitiva. El operador ? de Rust y los combinadores de Result como map y and_then recuperan la mayor parte de esa ergonomía. TypeScript no tiene un operador ?, pero puedes acercarte con helper functions:
function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
return result.ok ? { ok: true, value: fn(result.value) } : result;
}
function flatMap<T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> {
return result.ok ? fn(result.value) : result;
}
Sigue siendo más tosco que Rust. Si estás escribiendo TypeScript, librerías como neverthrow proporcionan un tipo Result con métodos encadenables que se sienten casi nativos. La sobrecarga es real, pero es menor de lo que parece una vez que tienes los helpers en su lugar.
Hay otro costo. Los stack traces de los errores lanzados son ricos y automáticos. Los errores devueltos son solo valores. Si quieres un stack trace, tienes que construirlo tú mismo. Para depurar, esto importa. Para el manejo predecible de errores en la lógica de negocio, usualmente no.
Cuándo Tiene Sentido Seguir Lanzando
No estamos abogando por cero excepciones. Algunos fallos son verdaderamente excepcionales y no deberían formar parte de tu modelo normal de errores. Un archivo faltante durante la carga de configuración es un fallo esperado. Un error de out-of-memory no lo es.
La regla general: si un fallo es parte de tu dominio, devuélvelo. Si es un bug de programación o un fallo catastrófico del sistema, lánzalo. Que JSON.parse lance una excepción con entrada malformada es molesto porque el JSON inválido es común. Que Array.prototype.map lance una excepción con un array nulo está bien porque llamar .map sobre null es un bug.
En la práctica, esto significa que tus I/O boundaries, parsers, validators y reglas de negocio deberían devolver errores. Tus invariantes, aserciones y condiciones verdaderamente catastróficas pueden seguir lanzándose.
Empieza por los Límites de tu API
No necesitas refactorizar todo tu codebase. Los lugares de mayor valor para adoptar retornos de error explícitos son tus public APIs y tus I/O layers. Estas son las fronteras donde quienes llaman necesitan saber qué puede salir mal.
Elige un module. Cambia sus tipos de retorno a Result<T, E>. Actualiza quienes lo llaman. Mira cómo se siente. El patrón es adictivo de una manera específica: una vez que dejas de buscar en los stack traces bloques try/catch faltantes, te das cuenta de cuánta carga mental te estaban costando las excepciones lanzadas.
La corrección a nivel de tipos para errores no se trata de escribir código perfecto. Se trata de escribir código que mienta menos.