Errores tipados

Escribe código en línea recta que falla con un error tipado. El enfoque de Arrow 2.x, la librería de Kotlin, portado a Dart.

Either<E, A> either<E, A>(A Function(Raise<E> r) block) Future<Either<E, A>> eitherAsync<E, A>(FutureOr<A> Function(Raise<E> r) block) A? nullable<A>(A Function(SingletonRaise r) block)
En profundidad. Esta página es el resumen; cada tema tiene un tutorial detallado con demos ejecutables: Either · either & el ámbito Raise · nullable · NonEmptyList · acumulación · Either × pipelines

De Kotlin Arrow a Dart

En Kotlin Arrow, un bloque either { } convierte una cadena de pasos que pueden fallar en código en línea recta: cada .bind() o bien desenvuelve un acierto, o bien cortocircuita el bloque entero con el fallo:

// Kotlin Arrow
fun getResult(): Either<Failure, SuccessData> = either {
    val user  = findUser(userId).bind()
    val order = findOrder(user.id).bind()
    val total = calculateTotal(order).bind()
    SuccessData(user, order, total)
}

FxDart te da la misma forma en Dart:

// FxDart
Either<Failure, SuccessData> getResult() => either((r) {
  final user  = r.bind(findUser(userId));
  final order = r.bind(findOrder(user.id));
  final total = r.bind(calculateTotal(order));
  return SuccessData(user, order, total);
});

Los dos sustituyen a la pirámide anidada de flatMap que tendrías que escribir si no:

// A lo que sustituye
Either<Failure, SuccessData> getResult() =>
    findUser(userId).flatMap((user) =>
        findOrder(user.id).flatMap((order) =>
            calculateTotal(order).map((total) =>
                SuccessData(user, order, total))));

Las dos diferencias con Kotlin son realidades de Dart: el ámbito es un parámetro explícito (r) porque Dart no tiene receptores en las lambdas, y la versión asíncrona tiene su propio constructor (eitherAsync) porque Dart no tiene inline. Por dentro esto no es encadenado con flatMap: igual que en Arrow, r.bind sobre un fallo lanza una señal privada, etiquetada con el ámbito, que el constructor captura en la frontera. Por eso los retornos tempranos, los bucles y los if funcionan sin más dentro del bloque, y por eso los constructores anidados nunca capturan los errores de los demás.

En profundidad: Either

El vocabulario del ámbito

Todo cuelga de la r que te entrega el constructor: escribe r. y lo irás descubriendo entero:

Either<String, int> parsePort(String raw) => either((r) {
  final n = r.ensureNotNull(int.tryParse(raw), () => '"$raw" no es un número');
  r.ensure(n > 0 && n < 65536, () => '$n está fuera de rango');
  return n;
});

switch (parsePort('8080')) {
  case Right(:final value): print('escuchando en $value');
  case Left(:final value):  print('configuración incorrecta: $value');
}

eitherAsync es el gemelo asíncrono (elevar errores solo dentro de la misma cadena de awaits); nullable/nullableAsync son los gemelos nullable-first que devuelven T? en lugar de un Either — FxDart es nullable-first, así que no hay tipo Option.

En profundidad: either & el ámbito Raise · En profundidad: nullable

Acumula todos los fallos, no solo el primero

Una validación quiere todos los errores, no el primero. Esta es la alternativa de Arrow a tener un tipo Validated aparte:

final user = either<Nel<String>, User>((r) => r.accumulate((acc) {
  final name = acc.accumulating((r) => validateName(r, input));
  final age  = acc.accumulating((r) => validateAge(r, input));
  return User(name.value, age.value); // todos los errores se informan juntos
}));

r.accumulate ejecuta todas las ramas y concatena todos los fallos en una NonEmptyList (Nel): un extension type de coste cero que no puede estar vacío. Los atajos de aridad fija r.zipOrAccumulate2..5 cubren los casos habituales, y r.mapOrAccumulate(items, transform) valida una colección entera en modo fail-slow. r.bindNel deja que una sola rama aporte varios errores a la vez, y someEither.toEitherNel() lleva un valor fail-fast a un ámbito acumulador.

En profundidad: acumulación → · En profundidad: NonEmptyList

Fusionados con los pipelines

Esta es la parte que no tienen ni Arrow ni ninguna librería de FP de Dart: errores tipados fusionados con los pipelines perezosos y conscientes de la concurrencia de FxDart.

// Valida 500 registros, 8 a la vez, y conserva TODOS los fallos — en orden.
final result = await fxStream(records)
    .mapOrAccumulate<String, User>((r, rec) async {
  final parsed = r.ensureNotNull(tryParse(rec), () => 'registro incorrecto: $rec');
  return await enrich(parsed);
}, concurrency: 8);

rights(), lefts(), separated(), sequence() (fail-fast: deja de tirar del pipeline en el primer Left) y mapOrAccumulate() (fail-slow) son terminales ansiosos sobre las cadenas fx()/asíncronas. La validación concurrente viaja por el mismo canal de retorno concurrent(n) que el resto de FxDart; cada elemento se ejecuta en su propio ámbito, así que el fallo de un elemento nunca puede filtrarse a otro.

En profundidad: Either × pipelines →

Excepciones frente a errores elevados

La frontera es tajante: los errores elevados son los fallos tipados de tu dominio; las excepciones lanzadas son defectos, y salen de either sin que nadie las toque. Para capturar un throw dentro de un Either, sé explícito:

final parsed = Either.catching(() => jsonDecode(raw));       // Either<Object, dynamic>
final typed  = Either.catchingWith(ParseFailure.new, () => jsonDecode(raw));

En profundidad: Either.catching vive en la página de Either

Dos reglas. (1) Nunca devuelvas un pipeline perezoso desde un bloque raise: materialízalo con toList() o usa los terminales ansiosos de arriba; un raise diferido falla ruidosamente con RaiseLeakedError. (2) Nunca hagas un catch pelado dentro de un bloque raise: usa catching/catchingAsync, que siempre dejan pasar la señal de cortocircuito (on Exception ya es seguro: la señal es un Error).

¿Tienes curiosidad por saber por qué esta página se llama errores tipados y no lleva una palabra clave de programación funcional como mónada? Las razones del nombre →