Errores como valores en TypeScript: el tipo Result

Una función que lanza una excepción no lo dice en su tipo: leerEdad(texto: string): number puede fallar y nada en la firma lo avisa. Una alternativa para los errores, muy usada en Rust y en Go, es devolverlos como un valor más. Con una unión discriminada, TypeScript obliga a mirar si salió bien antes de usar el resultado.

En un catch, el error es unknown

En JavaScript se puede lanzar cualquier cosa: un Error, un texto, un número. Por eso, en modo estricto, lo que llega al catch es unknown:

// En un catch, TypeScript no sabe qué se lanzó: en modo estricto, el error es unknown.
function leerEdad(texto: string): number {
  const n = Number(texto);
  if (!Number.isInteger(n) || n < 0) throw new Error(`«${texto}» no es una edad válida`);
  return n;
}

try {
  leerEdad("veinte");
} catch (e) {
  console.log(e.message);
}

Ábrelo en el editor y cámbialo →

npx tsc --noEmit --strict --target esnext --module nodenext --types node --locale es catch.ts
tsccatch.ts(11,15): error TS18046: "e" es de tipo "unknown".

Hay que comprobar qué es antes de usarlo:

function leerEdad(texto: string): number {
  const n = Number(texto);
  if (!Number.isInteger(n) || n < 0) throw new Error(`«${texto}» no es una edad válida`);
  return n;
}

try {
  leerEdad("veinte");
} catch (e) {
  // Antes de usarlo hay que comprobar qué es: alguien pudo hacer throw "texto" o throw 42.
  console.log(e instanceof Error ? e.message : String(e));
}

Ábrelo en el editor y cámbialo →

node catch-bien.ts
salida«veinte» no es una edad válida

El tipo Result

Un Resultado es una unión de dos variantes: un éxito con su valor o un fallo con su error. La propiedad ok las distingue. Los errores, en vez de mensajes sueltos, son una unión de nombres conocidos:

// Un resultado es un éxito con su valor o un fallo con su error. Nunca las dos cosas.
type Resultado<T, E = string> =
  | { ok: true; valor: T }
  | { ok: false; error: E };

const exito = <T>(valor: T): Resultado<T, never> => ({ ok: true, valor });
const fallo = <E>(error: E): Resultado<never, E> => ({ ok: false, error });

type ErrorEdad = "vacia" | "no-numerica" | "fuera-de-rango";

function leerEdad(texto: string): Resultado<number, ErrorEdad> {
  if (texto.trim() === "") return fallo("vacia");
  const n = Number(texto);
  if (!Number.isInteger(n)) return fallo("no-numerica");
  if (n < 0 || n > 130) return fallo("fuera-de-rango");
  return exito(n);
}

const mensajes: Record<ErrorEdad, string> = {
  "vacia": "Escribe tu edad",
  "no-numerica": "La edad tiene que ser un número entero",
  "fuera-de-rango": "Esa edad no parece real",
};

for (const entrada of ["34", "", "veinte", "200"]) {
  const r = leerEdad(entrada);
  if (r.ok) {
    console.log(`«${entrada}» -> edad ${r.valor}`);
  } else {
    console.log(`«${entrada}» -> ${mensajes[r.error]}`);
  }
}

Ábrelo en el editor y cámbialo →

npx tsc --noEmit --strict --target esnext --module nodenext --types node --locale es resultado.ts
node resultado.ts
salida«34» -> edad 34
«» -> Escribe tu edad
«veinte» -> La edad tiene que ser un número entero
«200» -> Esa edad no parece real

Ahora la firma lo dice todo: leerEdad devuelve un número o uno de tres errores concretos. Y gracias al Record<ErrorEdad, string>, si alguien añade un cuarto error, el objeto de mensajes deja de compilar hasta que le dé su texto.

No se puede olvidar el error

Con una excepción, nada obliga a poner un try. Con un Resultado, usar el valor sin mirar ok no compila:

type Resultado<T, E = string> = { ok: true; valor: T } | { ok: false; error: E };

declare function leerEdad(texto: string): Resultado<number, "vacia" | "no-numerica">;

const r = leerEdad("34");
console.log(r.valor + 1); // se nos olvidó mirar r.ok
npx tsc --noEmit --strict --target esnext --module nodenext --types node --locale es olvido.ts
tscolvido.ts(6,15): error TS2339: La propiedad 'valor' no existe en el tipo 'Resultado<number, "no-numerica" | "vacia">'.
  La propiedad 'valor' no existe en el tipo '{ ok: false; error: "no-numerica" | "vacia"; }'.

Encadenar pasos

Con una función que solo continúa si el paso anterior salió bien, se encadenan varias operaciones. El primer error que aparezca pasa hasta el final sin ejecutar el resto:

type Resultado<T, E = string> = { ok: true; valor: T } | { ok: false; error: E };
const exito = <T>(valor: T): Resultado<T, never> => ({ ok: true, valor });
const fallo = <E>(error: E): Resultado<never, E> => ({ ok: false, error });

// Aplica f solo si el paso anterior salió bien; si no, deja pasar el error tal cual.
function luego<T, U, E>(r: Resultado<T, E>, f: (v: T) => Resultado<U, E>): Resultado<U, E> {
  return r.ok ? f(r.valor) : r;
}

const aNumero = (s: string): Resultado<number> =>
  Number.isFinite(Number(s)) && s.trim() !== "" ? exito(Number(s)) : fallo(`«${s}» no es un número`);
const positivo = (n: number): Resultado<number> => (n > 0 ? exito(n) : fallo(`${n} no es positivo`));
const pesos = new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const conIva = (n: number): Resultado<string> => exito(`$${pesos.format(n * 1.16)} MXN`);

for (const s of ["1000", "-5", "mil"]) {
  const r = luego(luego(aNumero(s), positivo), conIva);
  console.log(s, "->", r.ok ? r.valor : `error: ${r.error}`);
}

Ábrelo en el editor y cámbialo →

npx tsc --noEmit --strict --target esnext --module nodenext --types node --locale es encadenar.ts
node encadenar.ts
salida1000 -> $1,160.00 MXN
-5 -> error: -5 no es positivo
mil -> error: «mil» no es un número
Lanzar excepcionesDevolver un Resultado
La firma avisa de que puede fallarNoSí
El compilador obliga a tratar el errorNoSí
Tipo del errorunknown en el catchEl que declares
Bueno paraFallos inesperados: sin memoria, un error de programaciónFallos esperados: datos inválidos, algo que no se encontró
No hay que elegir una sola forma. Lo habitual es combinarlas: Resultado para los errores que forman parte del funcionamiento normal, como una entrada inválida, y excepciones para lo que de verdad no debería pasar.

Todo el código de esta página se comprobó con TypeScript 7.0.2 en modo estricto (--strict --locale es) y se ejecutó con Node.js 24.22 en un contenedor limpio antes de publicarla; los mensajes de error y las salidas están copiados de esa ejecución. Documentación oficial: useUnknownInCatchVariables en la referencia de TSConfig.