{0,0}

Errores type-safe en TypeScript con generators

Descubrí por qué la mayoría de las librerías de manejo de errores type-safe en TypeScript están construidas usando generators.

Recientemente, se han creado muchas librerías para manejar errores en TypeScript de manera type-safe. Sabemos que en TypeScript podemos usar el bloque try/catch para manejar errores, pero no es type-safe, lo que significa que no podemos conocer el tipo específico del error que debemos manejar en tiempo de compilación. Aunque podemos usar el operador instanceof para verificar el tipo del error, no es una muy buena práctica porque no es realmente type-safe como las soluciones de otros lenguajes como Java o Rust.

Para resolver este problema, recientemente se han creado muchas librerías. Por ejemplo:

La mayoría tiene algo en común: están construidas usando generators de JavaScript. Algunas personas creen que los generators son solo una sintaxis más linda, pero no es así. De hecho, no son una sintaxis más linda si ya estás familiarizado con los bloques async/await y try/catch. Los generators se usan porque desbloquean una combinación muy específica de control de flujo + inferencia de tipos que es extremadamente difícil (podríamos decir imposible) de reproducir de forma limpia con funciones simples, promesas o encadenamiento de métodos.

Construyamos el concepto paso a paso.

Primero: ¿Qué es un iterator?

Antes de hablar de generators, necesitamos entender qué es un iterator. Un iterator es cualquier objeto que sigue una interfaz específica:

interface Iterator<T> {
  next(): {
    value: T;
    done: boolean;
  };
}

Entonces un iterator es simplemente algo con un método .next() que devuelve { value, done }. Y un Iterable es cualquier objeto que tiene un método [Symbol.iterator] que devuelve un iterator.

interface Iterable<T> {
  [Symbol.iterator](): Iterator<T>;
}

Qué son realmente los generators

Un generator es simplemente una función que puede pausar y reanudar la ejecución. También son una herramienta para crear iterators pero añadiendo otros condimentos que veremos más adelante.

function* generatorDemo() {
  const x = yield 1;
  return x + 1;
}

const it = generatorDemo();

console.log( it.next()  )  // { value: 1, done: false }
console.log( it.next(5) ); // { value: 6, done: true }

Idea clave:

  • yield pausa la ejecución y devuelve un valor
  • .next(value) reanuda la ejecución e inyecta un valor de vuelta

Entonces un generator es básicamente una ejecución controlable que podés recorrer manualmente. Pero la verdadera magia es yield*.

function* inner() {
  yield 1;
  yield 2;
  return 3;
}

function* outer() {
  const result = yield* inner();
  console.log(result);
}

const it = outer();

const a = it.next(); // 1 -> No loguea
const b = it.next(); // 2 -> No loguea
const c = it.next(); // loguea 3

console.log(a,b,c)

// Output:
// { value: 1, done: false }
// { value: 2, done: false }
// { value: undefined, done: true }

Algunos detalles técnicos importantes de entender:

  • function* es solo azúcar sintáctico para function que devuelve un generator. Eso significa que cuando se llama a la función, no se ejecutará inmediatamente, sino que devolverá un generator object que podemos usar para controlar la ejecución.
  • yield es simplemente una palabra clave que pausa la ejecución y devuelve un valor.
  • yield* es una palabra clave que pausa la ejecución y delega a otro generator.
  • .next() es un método que reanuda la ejecución y devuelve el valor del siguiente yield.

Veamos la ejecución paso a paso del ejemplo.

    1. Crear el iterator: Nada se ejecuta aún. El generator está pausado al inicio.
const it = outer();
    1. Primer .next()
const a = it.next();
// La ejecución comienza dentro de `outer`:
const result = yield* inner(); 
// Crea el iterator de `inner()` y empieza a ejecutarlo
    1. Dentro de inner():
yield 1;

Entonces:

  • inner hace yield de 1
  • yield* reenvía ese yield hacia afuera
  • outer se pausa. Nada se loguea aún.

Resultado:

a = { value: 1, done: false }
    1. Segundo .next()
const b = it.next();

Reanudamos donde lo dejamos.

Seguimos dentro de yield* inner().

Continuamos inner():

yield 2;

Entonces:

  • inner hace yield de 2
  • yield* lo reenvía
  • outer se pausa nuevamente

Resultado:

b = { value: 2, done: false }

Todavía sin console.log.

    1. Tercer .next()
const c = it.next();

Reanudamos de nuevo.

Ahora inner() continúa:

return 3;

Importante:

  • esto no hace yield
  • finaliza el iterator
  • devuelve { value: 3, done: true } a yield*

Ahora yield* hace esto:

const result = 3;

Entonces ahora outer continúa:

console.log(result); // loguea 3

Luego outer termina (sin return → undefined)

El resultado final es:

c = { value: undefined, done: true }

Output final:

a = { value: 1, done: false }
b = { value: 2, done: false }
c = { value: undefined, done: true }

Y durante el tercer .next() el número 3 se loguea porque es el valor de retorno del generator inner().

Esta línea:

const result = yield* inner();

significa:

“Ejecutá inner() completamente. Reenviá todos sus yields. Cuando termine, dame su valor de retorno.”

Nota: En este punto deberías poder explicar qué es un iterator, cómo funcionan los generators y cómo se usan para crear iterators, la diferencia entre yield y yield* y cómo funcionan juntos. Si no entendés esto, probablemente deberías releer esta introducción y el ejemplo porque es la base de la siguiente parte del artículo.

Iterators que hacen yield explícitamente (control de flujo en acción)

Ahora construyamos un iterator que siempre hace yield una vez:

const AlwaysYield = (value: string) => ({
  *[Symbol.iterator]() {
    console.log(“a punto de hacer yield”);
    yield value;
    console.log(“esto solo corre si se reanuda”);
    return “done”;
  },
});

Usándolo:

function* test() {
  const result = yield* AlwaysYield(“pausa aquí”);
  console.log(“result:”, result);
}

const it = test();

it.next();
// loguea: “a punto de hacer yield”
// { value: “pausa aquí”, done: false }

Notá:

  • la ejecución se pausó
  • ”result:” nunca se logueó
  • el generator está ahora congelado

Si lo reanudamos:

it.next();
// loguea: “esto solo corre si se reanuda”
// loguea: “result: done”
// { value: undefined, done: true }

Conclusión clave:

Si un iterator hace yield, fuerza al generator externo a pausarse.

El truco: los iterators no tienen que hacer yield

Acá está el primer momento “espera… ¿cómo?”.

const Ok = <T>(value: T) => ({
  type: “Ok”,
  value,
  *[Symbol.iterator]() {
    return this.value;
  },
});

function* example() {
  const a = yield* Ok(5);
  return a;
}

const it = example();
it.next(); // { value: 5, done: true }

No hubo ninguna pausa.

Entonces Si un iterator no hace yield, yield* se comporta como una llamada a función normal.

Lo opuesto: iterators que siempre hacen yield

const Err = <E>(error: E) => ({
  type: “Err”,
  error,
  *[Symbol.iterator]() {
    yield { type: “Err”, error: this.error };
  },
});

function* example() {
  const a = yield* Err(“boom”);
  return a;
}

const it = example();

it.next();
// { value: { type: “Err”, error: “boom” }, done: false }

Ahora el generator está pausado. Y nada más se ejecuta.

Combinando los dos

Ahora tenemos dos comportamientos:

TipoComportamiento
OkNO hace yield → la ejecución continúa
Errhace yield una vez → la ejecución se pausa

Esta es la base completa del manejo de errores basado en generators.

Ahora escribamos el ejecutor más simple posible:

function run(gen: () => Generator<any, any, any>) {
  const it = gen();
  const state = it.next(); // solo una vez
  return state.value;
}

Eso es todo. Si un generator no hace yield (todo es un iterator Ok), la función simplemente devolverá el valor. Si un generator hace yield (se encuentra algún Err y tiene una instrucción yield en el iterator), la función se pausará y nunca se reanudará, por lo que el valor será el objeto Err.

Internamente, el ejecutor funciona como un iterator con una especie de mecanismo de early return cuando se encuentra un Err. No es un mecanismo de early return real porque el generator no se detiene, es solo una forma de detener la ejecución del generator y devolver el objeto Err.

Analizando todo junto

const Ok = <T>(value: T) => ({
  type: “Ok”,
  value,
  *[Symbol.iterator]() {
    return this.value;
  },
});

const Err = <E>(error: E) => ({
  type: “Err”,
  error,
  *[Symbol.iterator]() {
    yield { type: “Err”, error: this.error };
  },
});

function run(gen: () => Generator<any, any, any>) {
  const it = gen();
  const state = it.next();
  return state.value;
}

Caso exitoso (paso a paso)

  const result = run(function* () {
  const a = yield* Ok(1);
  const b = yield* Ok(2);
  return yield* Ok(a + b);
});

Ejecución:

  1. Inicia el generator
  2. yield* Ok(1) → sin yield → a = 1
  3. yield* Ok(2) → sin yield → b = 2
  4. return yield* Ok(3)

Output: 3

Caso de error (paso a paso)

const result = run(function* () {
  const a = yield* Err(“fail”);
  const b = yield* Ok(2); // nunca corre
  return yield* Ok(a + b);
});

Ejecución:

  1. Inicia el generator
  2. yield* Err(“fail”)
  3. Err hace yield → el generator se pausa
  4. run() devuelve ese valor inmediatamente

Output:

{ type: “Err”, error: “fail” }

Notá:

  • b nunca se evalúa
  • sin sentencias if
  • sin excepciones

Qué significa esto realmente

Este patrón funciona porque codificamos el control de flujo en el iterator protocol.

  • Éxito = “no hacer nada especial”
  • Error = “pausar la ejecución”

Y luego solo ejecutamos el generator una vez y tratamos la primera pausa como el resultado.

Ahora lo que quería hablar: Por qué TypeScript ama esto

Ahora llegamos a la verdadera razón por la que se usan los generators.

El tipo de yield se convierte en una unión

type Err<_T, E> = { type:Err”; error: E };

type InferYieldErr<Y> = Y extends Err<never, infer E> ? E : never;

Si tu generator hace yield (a nivel de tipos):

Err<never, A> | Err<never, B>

TypeScript infiere:

A | B

Imaginemos que tenemos los siguientes tipos como una implementación más simple del tipo Result incluido en algunas de las librerías de ejemplo. Cada variante es iterable para que yield* pueda delegar: Ok completa inmediatamente con el valor de éxito; Err hace yield una vez para que el driver externo pueda observar el error y detenerse.

Las librerías suelen exponer [Symbol.iterator] como un generator method *[Symbol.iterator]() en Ok / Err: el cuerpo puede hacer yield o return, y TypeScript lo tipifica como Generator<Yield, Return, Next>. El * al inicio es sintaxis de JavaScript para una generator function (incluidos los métodos), no un operador TypeScript separado; el compilador solo agrega verificación estática de yields, valores de finalización y argumentos next opcionales.

type Err<_T, E> = {
  type:Err”;
  error: E;
  [Symbol.iterator](): Generator<Err<never, E>, never, unknown>;
};

type Ok<T, E = never> = {
  type:Ok”;
  value: T;
  [Symbol.iterator](): Generator<Err<never, E>, T, unknown>;
};

type Result<T, E> = Ok<T, E> | Err<T, E>;

/** Cualquier par Ok/Err — usar `unknown` para que la inferencia fluya a través de `R` en `Result.gen`. */
type AnyResult = Ok<unknown, unknown> | Err<unknown, unknown>;

function ok<T, E = never>(value: T): Ok<T, E> {
  return {
    type: “Ok”,
    value,
    *[Symbol.iterator](): Generator<Err<never, E>, T, unknown> {
      return this.value;
    },
  };
}

function err<T = never, E = unknown>(error: E): Err<T, E> {
  const result: Err<T, E> = {
    type: “Err”,
    error,
    *[Symbol.iterator](): Generator<Err<never, E>, never, unknown> {
      yield result as unknown as Err<never, E>;
      return undefined as never;
    },
  };
  return result;
}

/** Discriminado — mantiene `ErrorA | ErrorB` en las uniones de error (las clases simples `msg: string` colapsan). */
class ErrorA {
  readonly _tag = “ErrorA” as const;
}
class ErrorB {
  readonly _tag = “ErrorB” as const;
}

const getA = (): Result<number, ErrorA> => ok(1);

const getB = (): Result<number, ErrorB> => err(new ErrorB());

type InferYieldErr<Y> = Y extends Err<never, infer E> ? E : never;
type InferOk<R> = R extends Ok<infer T, unknown> ? T : never;
type InferErr<R> = R extends Err<unknown, infer E> ? E : never;

/**
 * Infiere `Yield` y `R` de `Generator<Yield, R, unknown>` directamente.
 * Evita `G extends Generator<infer Y, …>` — ese camino puede perder miembros de la unión de yield.
 */
function gen<Yield extends Err<never, unknown>, R extends AnyResult>(
  fn: () => Generator<Yield, R, unknown>,
): Result<InferOk<R>, InferYieldErr<Yield> | InferErr<R>> {
  const it = fn();
  const state = it.next();
  return state.value as Result<InferOk<R>, InferYieldErr<Yield> | InferErr<R>>;
}

const result = gen(function* () {
  const a = yield* getA();
  const b = yield* getB();
  return ok(a + b);
});

console.log(result);

TypeScript infiere:

Result<number, ErrorA | ErrorB>

Pero ¿Por qué generators (y no otra cosa)?

Creo que hay 3 razones principales por las que se usan generators:

  1. Separan el control de flujo de la lógica
  2. Permiten early return sin return
  3. Se integran con el sistema de tipos de TypeScript

Exploremos cada una.

1. Separan el control de flujo de la lógica

Escribís:

const a = yield* getA();

En lugar de:

const r = getA();
if (r is error) return r;

2. Permiten early return sin return

Sin excepciones. Sin bifurcaciones. Solo:

  • yield → pausa
  • no reanudar → salida
  • código más limpio (sin sentencias if en cada llamada)

3. Se integran con el sistema de tipos de TypeScript

Esta es la característica killer y la razón principal por la que TypeScript ama este patrón. Aunque creo que TypeScript debería proporcionar una forma más nativa de manejar errores, esta es una manera de lograrlo usando características existentes del sistema de tipos del lenguaje. Principalmente porque:

  • todos los tipos de yield se recopilan en una unión -> Tenemos errores como valores y también errores y casos de éxito en el mismo tipo.
  • TypeScript puede extraer tipos de error automáticamente
  • Aprovechamos la inferencia que TypeScript proporciona para los retornos en generators para obtener el tipo resultado

Los generators se usan para el manejo de errores type-safe en TypeScript no porque sean convenientes sino porque combinan de forma única:

  • ejecución lazy
  • control de flujo interrumpible
  • delegación composable (yield*)
  • inferencia a nivel de tipos sobre uniones

Esa combinación es lo que permite:

manejo de errores limpio, lineal y type-safe con cero bifurcaciones en tiempo de ejecución.

Si entendés esto, no solo entendés una librería, ahora entendés por qué múltiples librerías convergen independientemente en el mismo patrón: los generators.

Un pequeño disclaimer: todos los ejemplos y explicaciones aquí son más simples que las implementaciones reales de las librerías. Las implementaciones reales son más complejas porque manejan más casos como operaciones asincrónicas, múltiples yields, mapeo de errores, encadenamiento de resultados, etc. Pero el concepto central es el mismo.

¡Espero que hayas disfrutado este artículo! Si tenés algún comentario, no dudes en contactarme. ¡Hasta la próxima!