Introducción
Un throw new Error("Algo ha fallado") es rápido de escribir, pero suele quedarse corto cuando el fallo forma parte de una regla esperada: email duplicado, stock agotado, permiso insuficiente o datos inválidos.
En esos casos no estamos ante una excepción misteriosa; estamos ante un resultado posible de la operación. TypeScript nos permite modelarlo de forma explícita con uniones discriminadas. El beneficio es doble: el código obliga a tratar el error y la interfaz puede decidir qué mensaje o estado mostrar sin depender de comparar strings.
El problema: errores que no explican qué puede pasar
Supongamos un registro de usuario sencillo:
Quien llame a la función no puede saber por su tipo que debe manejar esos casos. Un try/catch recibe unknown, así que termina apareciendo lógica frágil como error.message === "User already exists".
Un Result pequeño y explícito
No hace falta incorporar una librería para resolverlo. Una unión discriminada mínima nos da un contrato claro.
La propiedad ok es el discriminante. Al comprobarla, TypeScript sabe automáticamente si tenemos acceso a value o a error.
Definir errores del dominio, no mensajes sueltos
El siguiente paso es identificar los fallos que forman parte del contrato del caso de uso.
Los literales de type son estables y fáciles de manejar. Los datos extra solo aparecen donde aportan información: weak_password incluye la longitud requerida, mientras que el email duplicado no necesita exponer datos sensibles.
El caso de uso devuelve todas sus salidas posibles
Ahora el contrato cuenta toda la historia de la operación.
Observa el límite: seguimos dejando que los fallos inesperados —por ejemplo, que la base de datos no responda— lancen una excepción. No queremos envolver cada posible error técnico en un Result. Modelamos los resultados de negocio que quien llama puede resolver deliberadamente.
La capa de entrega traduce, no inventa reglas
Un controlador HTTP puede convertir los errores de dominio en respuestas sin que el caso de uso conozca HTTP.
No hay default intencionadamente. Si añadimos un nuevo error al tipo, TypeScript puede avisarnos de que falta contemplarlo. Para reforzarlo todavía más, podemos usar una comprobación exhaustiva:
Y añadir default: return assertNever(result.error) al switch. Al aparecer un nuevo tipo de error, la compilación fallará hasta que decidamos cómo responder.
Tests centrados en el contrato
Los tests pueden verificar el resultado sin depender de excepciones ni de infraestructura.
El test expresa una regla de negocio concreta: un email ya registrado no genera una segunda cuenta. No necesita conocer el controlador, una respuesta HTTP ni una base de datos.
Cuándo usar Result y cuándo lanzar una excepción
Un criterio sencillo:
- Usa
Resultpara resultados esperados que la capa llamadora debe transformar en una decisión: validación, conflictos, reglas de estado o permisos. - Lanza excepciones para situaciones inesperadas o irrecuperables en ese nivel: una conexión caída, un bug, una configuración ausente.
- No mezcles ambos para el mismo caso. Si "email duplicado" es un resultado, no debería a veces devolver
Resulty a veces lanzarError.
La consistencia es más valiosa que una abstracción sofisticada. En un módulo pequeño quizá baste con devolver un objeto de error específico; en un dominio amplio, un Result compartido hace que los contratos sean más fáciles de descubrir.
Conclusión
El código limpio no elimina los errores: los hace visibles en el lugar correcto. Una unión discriminada convierte los fallos esperados en parte de la firma del caso de uso, y separa las reglas de negocio de su traducción a HTTP, React o cualquier otro borde.
La próxima vez que vayas a lanzar una excepción por una regla que el usuario puede corregir, pregúntate si en realidad estás describiendo un resultado válido de la operación. Si lo estás, TypeScript puede ayudarte a no olvidarlo.