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:

register-user.ts
export async function registerUser(email: string, password: string) {
if (!email.includes("@")) {
throw new Error("Invalid email")
}

const existingUser = await userRepository.findByEmail(email)
if (existingUser) {
throw new Error("User already exists")
}

return userRepository.save({ email, password })
}

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.

result.ts
export type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E }

export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value })

export const err = <E>(error: E): Result<never, E> => ({ ok: false, error })

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.

register-user-errors.ts
export type RegisterUserError =
| { type: "invalid_email"; message: string }
| { type: "weak_password"; minLength: number }
| { type: "email_already_registered" }

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.

register-user.ts
import { err, ok, Result } from "./result"
import { RegisterUserError } from "./register-user-errors"

type User = { id: string; email: string }

type UserRepository = {
findByEmail(email: string): Promise<User | null>
save(input: { email: string; passwordHash: string }): Promise<User>
}

type Dependencies = {
users: UserRepository
hashPassword: (password: string) => Promise<string>
}

export function registerUser({ users, hashPassword }: Dependencies) {
return async (
email: string,
password: string
): Promise<Result<User, RegisterUserError>> => {
if (!email.includes("@")) {
return err({ type: "invalid_email", message: "El email no tiene un formato válido" })
}

const minPasswordLength = 12
if (password.length < minPasswordLength) {
return err({ type: "weak_password", minLength: minPasswordLength })
}

const existingUser = await users.findByEmail(email)
if (existingUser) {
return err({ type: "email_already_registered" })
}

const passwordHash = await hashPassword(password)
const user = await users.save({ email, passwordHash })
return ok(user)
}
}

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.

register-user-controller.ts
export async function registerUserController(request: Request) {
const { email, password } = await request.json()
const result = await registerUserUseCase(email, password)

if (result.ok) {
return Response.json(result.value, { status: 201 })
}

switch (result.error.type) {
case "invalid_email":
return Response.json({ error: result.error.message }, { status: 400 })

case "weak_password":
return Response.json(
{ error: `La contraseña debe tener al menos ${result.error.minLength} caracteres` },
{ status: 400 }
)

case "email_already_registered":
return Response.json({ error: "Ya existe una cuenta con este email" }, { status: 409 })
}
}

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:

assert-never.ts
export function assertNever(value: never): never {
throw new Error(`Caso no contemplado: ${JSON.stringify(value)}`)
}

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.

register-user.test.ts
it("devuelve email_already_registered sin guardar un usuario", async () => {
const save = vi.fn()
const useCase = registerUser({
users: {
findByEmail: async () => ({ id: "user-1", email: "ana@example.com" }),
save,
},
hashPassword: async () => "hash",
})

const result = await useCase("ana@example.com", "una-contraseña-segura")

expect(result).toEqual({ ok: false, error: { type: "email_already_registered" } })
expect(save).not.toHaveBeenCalled()
})

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 Result para 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 Result y a veces lanzar Error.

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.