Las mónadas de las que va este artículo no nacieron como librería. Nacieron como una carpeta dentro de DevSweep.
Cuando migré DevSweep de Bash a .NET 10 necesitaba modelar el error sin excepciones, y acabé escribiéndome un Result, luego un Option, luego un Unit. Funcionaban, estaban testeados, y se quedaron ahí, viviendo felices dentro del dominio de DevSweep. Hasta que empecé otro proyecto y me encontré a mí mismo haciendo Ctrl+C, Ctrl+V de esos mismos ficheros.
Ese es el momento exacto en el que un puñado de clases deja de ser "código de un proyecto" y pasa a ser una librería. Si lo estás copiando, es que ya no pertenece a ningún proyecto en concreto. Así que extraje las mónadas, las pulí, les añadí las que faltaban y publiqué SharpMonads.Core en NuGet. Cuatro tipos, cero dependencias, y un dotnet add package en lugar de un copia-pega.
En este artículo te cuento qué hay dentro, por qué tomé cada decisión de diseño y —porque es la pregunta que me harías tú— por qué no usé directamente LanguageExt.
El problema: el null y la excepción son mentiras
Cuando una función devuelve User pero a veces devuelve null, la firma te está mintiendo. Dice que siempre hay un usuario, y no es verdad. Cuando otra devuelve decimal pero a veces lanza InvalidOperationException, vuelve a mentir: el fallo no aparece por ningún lado, vive escondido en la documentación —si hay suerte— o en un try/catch que alguien recordará poner.
El problema no es que el código falle. El problema es que el fallo es invisible para el compilador. Y todo lo que el compilador no ve, lo acabas viendo tú en producción.
Esto es justo lo que me dolía en DevSweep: una herramienta que borra ficheros del disco no se puede permitir errores invisibles. Las mónadas resuelven esto de una forma casi aburrida de tan simple: hacen explícito en el tipo aquello que antes estaba implícito. Si algo puede no existir, lo dices con Option<T>. Si algo puede fallar, lo dices con Result<TValue, TError>. Y a partir de ahí, el compilador trabaja para ti.
Option<T>: el adiós definitivo al null
Option<T> representa un valor que puede estar o no estar. Donde antes escribías User? y rezabas, ahora escribes Option<User> y compones.
Option<int> some = Option<int>.Some(42);
Option<int> none = Option<int>.None;
// Map transforma el valor si está presente
Option<int> doubled = some.Map(x => x * 2); // Some(84)
// Bind encadena operaciones que a su vez devuelven un Option
Option<int> Parse(string s) =>
int.TryParse(s, out var n) ? Option<int>.Some(n) : Option<int>.None;
Option<int> parsed = Option<string>.Some("10").Bind(Parse); // Some(10)
// Match colapsa las dos ramas en un único valor
string label = some.Match(
onSome: value => $"Got {value}",
onNone: () => "Nothing here");Fíjate en que en ningún momento preguntas "¿hay valor?". No hay if (x != null). La estructura te obliga a tratar las dos posibilidades, y lo hace sin que se note. Eso es lo que buscamos: que lo correcto sea también lo cómodo.
Result<TValue, TError>: el error como ciudadano de primera
Result fue la primera mónada que escribí para DevSweep, y sigue siendo la que más uso. Modela operaciones que pueden fallar de forma esperada y recuperable, sin recurrir a excepciones. Un éxito lleva un TValue; un fallo lleva un TError. Y lo importante: el tipo del error forma parte de la firma.
Result<int, string> Divide(int a, int b) =>
b == 0
? Result<int, string>.Failure("divide by zero")
: Result<int, string>.Success(a / b);
Result<int, string> chained = Result<int, string>.Success(42)
.Bind(x => Divide(x, 2)); // Success(21)Aquí es donde aparece una de mis metáforas favoritas para entender Bind: tu código tiene dos vías, la del éxito y la del error. Mientras todo va bien, viajas por la vía del éxito y cada Bind te lleva al siguiente tramo. En cuanto algo falla, te cambias a la vía del error y te quedas ahí: los siguientes pasos se saltan solos. No hay que comprobar nada entre medias. Es el mismo railway-oriented programming del que ya hablé en la serie de DevSweep, ahora empaquetado para llevar.
Donde Result se vuelve adictivo es en los casos reales. Una secuencia de operaciones falibles que quieres tratar como una sola:
Result<IReadOnlyList<int>, string> numbers =
new[] { "1", "2", "3" }.Collect(Parse); // Success([1, 2, 3])Collect recorre la secuencia y corta en el primer fallo. Si los tres valores parsean, tienes una lista; si uno falla, tienes ese error y nada más. Toda la lógica de "para en cuanto algo vaya mal" desaparece de tu código y vive en un único sitio.
Y cuando entra el async, que en una aplicación real entra siempre, los combinadores siguen ahí:
Result<string, string> name = await Result<int, string>.Success(1)
.BindAsync(LoadUserAsync) // un paso async que puede fallar
.TapAsync(AuditAsync) // un efecto de lado, manteniendo el valor
.MapAsync(user => user.Name); // transformar el valor de éxitoBindAsync, MapAsync y TapAsync funcionan tanto sobre Result<...> como sobre Task<Result<...>>. Eso significa que puedes encadenar la pipeline entera sin un solo await intermedio ensuciando la expresión. El detalle parece menor hasta que escribes la décima pipeline y te das cuenta de que no has tenido que desempaquetar nada a mano.
Either<TLeft, TRight>: dos tipos, una decisión
Either es el primo generalista de Result, y la última mónada que añadí al extraer la librería. Representa un valor que es de uno de dos tipos posibles. Por convención de la programación funcional es right-biased: Right es el camino feliz y Left la alternativa, así que Map y Bind operan sobre Right y dejan pasar Left intacto.
Either<string, int> right = Either<string, int>.FromRight(42);
Either<string, int> left = Either<string, int>.FromLeft("boom");
// Map transforma el Right; el Left pasa de largo
Either<string, string> mapped = right.Map(x => x.ToString());
// MapLeft transforma el Left; el Right pasa de largo
Either<int, int> mappedLeft = left.MapLeft(e => e.Length);
// Swap da la vuelta a las dos vías
Either<int, string> swapped = right.Swap(); // FromLeft(42)¿Y por qué Either si ya tengo Result? Porque no todo "lo otro" es un error. A veces las dos ramas son legítimas: un valor que viene de la caché o de la base de datos, una respuesta que es texto o número, un flujo que se bifurca. Result te empuja a leer Left como "fallo"; Either no juzga. Tener los dos te deja elegir la semántica correcta en cada caso en lugar de forzar todo a "éxito/error".
La decisión de diseño que lo sostiene todo: readonly record struct
Si abres cualquiera de las mónadas, te encuentras siempre la misma firma:
public readonly record struct Either<TLeft, TRight>
{
private readonly TLeft? left;
private readonly TRight? right;
// ...
}Hay varias decisiones aquí que merece la pena comentar.
Son **struct**, no **class**. Una mónada es un envoltorio finísimo alrededor de un valor. Si fuera una clase, cada Option o cada Result sería una asignación en el heap, y de repente el coste de "ser elegante" se paga en presión sobre el recolector de basura. Como struct, viven en la pila y el envoltorio es prácticamente gratis. Programación funcional sin peaje de rendimiento.
Son **readonly**. Una vez construida, una mónada no cambia. Esto no es dogma funcional por el dogma: la inmutabilidad es lo que hace que Map y Bind siempre devuelvan algo nuevo en lugar de mutar lo que tenías, y es lo que hace que razonar sobre una pipeline sea posible.
Son **record**. Gratis te llevas la igualdad por valor. Dos Option<int>.Some(42) son iguales, y eso —ya lo verás— resulta ser justo lo que necesitas para testear.
Y un detalle pequeño pero deliberado: el acceso indebido no se ignora, se castiga.
public TRight Right => IsRight
? right!
: throw new InvalidOperationException("Cannot access Right of a Left value");Si intentas leer el Right de un Left, saltas por los aires de inmediato. Nada de devolver un default silencioso que te explota tres capas más arriba. El camino correcto es usar Match; el atajo peligroso te lo dice a la cara.
Azúcar de LINQ: las mónadas hablan tu idioma
C# tiene una sintaxis de consulta preciosa que casi nadie usa fuera de las colecciones. Pero from ... select no es más que SelectMany y Select con buena cara. Así que se los implementé a las mónadas:
Either<string, int> total =
from a in Either<string, int>.FromRight(10)
from b in Either<string, int>.FromRight(5)
select a + b; // FromRight(15)Esto no es un truco; es exactamente la misma composición de Bind y Map de antes, pero leída de arriba abajo como una receta. Si cualquiera de los dos pasos fuera Left, el resultado entero sería Left y el select ni se ejecutaría. La vía del error otra vez, ahora disfrazada de consulta.
Por dentro es de una sencillez casi decepcionante:
public static Either<TLeft, TOut> SelectMany<TLeft, TRight, TIntermediate, TOut>(
this Either<TLeft, TRight> either,
Func<TRight, Either<TLeft, TIntermediate>> binder,
Func<TRight, TIntermediate, TOut> projector)
=> either.Bind(right =>
binder(right).Map(intermediate =>
projector(right, intermediate)));Todo el from/select del mundo se reduce a un Bind y un Map. Esa es la gracia de las mónadas: una vez tienes las dos operaciones, lo demás es decoración.
¿Cómo sé que esto son mónadas de verdad?
Aquí podría haberme quedado en "compila y los ejemplos funcionan". Pero una mónada no es cualquier tipo con un método llamado Bind. Es un tipo que cumple tres leyes: identidad por la izquierda, identidad por la derecha y asociatividad. Si tu Bind no las cumple, tienes algo que se parece a una mónada y se romperá justo cuando dejes de mirar.
Así que las testeé. Una por una, con TUnit:
[Test]
[Arguments(10)]
[Arguments(-1)]
public async Task SatisfyTheAssociativityLaw(int value)
{
var either = Either<string, int>.FromRight(value);
var chained = either.Bind(Increment).Bind(Double);
var nested = either.Bind(x => Increment(x).Bind(Double));
await Assert.That(chained).IsEqualTo(nested);
}¿Te acuerdas de que las hice record? Aquí está la recompensa: IsEqualTo compara por valor, así que la ley se escribe como lo que es —"estas dos formas de encadenar dan el mismo resultado"— sin un solo desempaquetado manual. La decisión de diseño y el test encajan como dos piezas pensadas a la vez. Porque lo fueron.
Más allá de las tres leyes, los tests cubren que Map es consistente con Bind, que Match elige la rama correcta y, sobre todo, que el cortocircuito funciona: que un Left ignora de verdad todos los Map, Bind, BindAsync y MapAsync que le eches encima. Porque la promesa de la vía del error no vale nada si no está verificada.
¿Y por qué no LanguageExt?
Es la pregunta inevitable, así que la respondo de frente. LanguageExt existe, tiene más de 7.000 estrellas, lleva años de desarrollo y es, sin discusión, una obra de ingeniería impresionante. Trae Option, Either, Validation, Fin, Try, las mónadas Reader/Writer/State, colecciones inmutables propias (Seq, Map, Lst...), efectos IO, combinadores de parser, higher-kinded types simulados y un sistema de traits con más de veinte abstracciones. Lo hace todo.
Y ese "lo hace todo" es exactamente el motivo por el que no lo usé.
No es que LanguageExt sea peor; es que resuelve un problema distinto. LanguageExt te pide que te conviertas a su forma de pensar: que aprendas su dialecto, sus colecciones, su sistema de traits. A cambio te da un ecosistema funcional completo. Es una decisión de arquitectura para todo un proyecto, no una dependencia que añades a la ligera.
Yo no necesitaba convertir DevSweep al paradigma funcional. Necesitaba tres tipos que hicieran explícitos el null y el error, que se leyeran como C# normal y que cualquiera del equipo entendiera en cinco minutos sin abrir un tutorial. Meter LanguageExt entero para usar Option y Result es como instalar un sistema operativo para abrir un PDF.
Esa es la regla que me marqué: SharpMonads.Core tiene que caber en tu cabeza. Si para usar una librería de mónadas necesitas estudiar la librería de mónadas, algo ha salido mal. Si tu proyecto de verdad vive y respira programación funcional, usa LanguageExt sin dudarlo. Si solo quieres dejar de devolver null, quédate con algo que puedas leer entero en una sentada.
El detalle de fontanería: publicar sin secretos
Un apunte fuera del código, porque era la pieza que faltaba para convertir "una carpeta de DevSweep" en "un paquete de verdad". El paquete se publica en NuGet desde GitHub Actions usando Trusted Publishing con OIDC: nada de subir un API key como secreto del repo que tarde o temprano se filtra o caduca. GitHub se identifica ante NuGet con un token efímero, y el workflow corre los tests antes de publicar. Si los tests no pasan, no hay release. Parece obvio dicho así, pero es la clase de cosa que se olvida hasta el día que publicas algo roto.
Lo que me llevo de este proyecto
SharpMonads.Core empezó siendo código de usar y tirar dentro de otro proyecto, y se ha convertido en la dependencia que ahora arrastro a todos los demás. Esa trayectoria —de carpeta a paquete— es probablemente la lección más útil: las mejores librerías pequeñas no se diseñan, se destilan. Aparecen cuando te pillas a ti mismo copiando el mismo código por segunda vez.
Tres ideas me quedan dándome vueltas:
-
Si lo copias por segunda vez, extráelo. El copia-pega entre proyectos es la señal más honesta de que algo merece vivir solo.
-
La elegancia no tiene por qué costar rendimiento.
readonly record structes la prueba de que puedes tener composición funcional sin pagar una asignación por cada paso. -
El alcance es una decisión de diseño. Decir que no a las otras 50 funcionalidades de LanguageExt fue tan importante como decir que sí a
Bind. Una librería que cabe en tu cabeza es una librería que la gente usa.
Tienes el código en GitHub y el paquete en NuGet:
dotnet add package SharpMonads.CoreLa próxima parada es seguir destilando: Validation para acumular errores en vez de cortar en el primero es la candidata más clara, porque es justo lo que ya empiezo a echar de menos. Pero eso será otra historia.
¡Nos vemos en el siguiente post!