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 ·
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.
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');
}
r.bind(either)/r.bindAll(eithers)— desenvuelve o cortocircuita.r.ensure(cond, () => err)— elrequirecon error tipado.r.ensureNotNull(x, () => err)— devuelve un valor no nulo, con promoción de tipo.r.recover(block, onRaise)— trata un error elevado en un ámbito anidado.r.withError(transform, block)— adapta otro tipo de error a este ámbito.r.raise(err)— cortocircuita directamente; devuelveNever.
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 →
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 →