Skip to main content

SharpMonads.Core: From a Script to a NuGet Package

2026-06-1412 min
.NETOpen SourceClean Code

The monads this article is about weren't born as a library. They were born as a folder inside DevSweep.

When I migrated DevSweep from Bash to .NET 10, I needed to model errors without exceptions, and I ended up writing a Result, then an Option, then a Unit. They worked, they were tested, and they stayed there, living happily inside DevSweep's domain. Until I started another project and caught myself doing Ctrl+C, Ctrl+V of those very same files.

That's the exact moment when a handful of classes stops being "one project's code" and becomes a library. If you're copying it, it no longer belongs to any specific project. So I extracted the monads, polished them, added the ones that were missing, and published SharpMonads.Core on NuGet. Four types, zero dependencies, and a dotnet add package instead of a copy-paste.

In this article I'll tell you what's inside, why I made each design decision and —because it's the question you'd ask me— why I didn't just use LanguageExt.

The problem: null and exceptions are lies

When a function returns User but sometimes returns null, the signature is lying to you. It says there's always a user, and that's not true. When another returns decimal but sometimes throws InvalidOperationException, it lies again: the failure shows up nowhere, it hides in the documentation —if you're lucky— or in a try/catch that someone will remember to add.

The problem isn't that the code fails. The problem is that the failure is invisible to the compiler. And everything the compiler doesn't see, you end up seeing in production.

This is exactly what hurt in DevSweep: a tool that deletes files from disk can't afford invisible errors. Monads solve this in a way that's almost boring in its simplicity: they make explicit in the type what used to be implicit. If something might not exist, you say so with Option<T>. If something might fail, you say so with Result<TValue, TError>. And from there, the compiler works for you.

Option<T>: the definitive goodbye to null

Option<T> represents a value that may or may not be there. Where you used to write User? and pray, you now write Option<User> and compose.

c#
Option<int> some = Option<int>.Some(42);
Option<int> none = Option<int>.None;

// Map transforms the value if it is present
Option<int> doubled = some.Map(x => x * 2);   // Some(84)

// Bind chains operations that themselves return an 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 collapses the two branches into a single value
string label = some.Match(
    onSome: value => $"Got {value}",
    onNone: () => "Nothing here");

Notice that you never ask "is there a value?". There's no if (x != null). The structure forces you to handle both possibilities, and it does so seamlessly. That's what we're after: making the correct thing also the comfortable thing.

Result<TValue, TError>: errors as first-class citizens

Result was the first monad I wrote for DevSweep, and it's still the one I use most. It models operations that can fail in an expected, recoverable way, without resorting to exceptions. A success carries a TValue; a failure carries a TError. And the important part: the error type is part of the signature.

c#
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)

This is where one of my favorite metaphors for understanding Bind comes in: your code has two tracks, the success track and the error track. While everything goes well, you travel on the success track and each Bind takes you to the next stretch. The moment something fails, you switch to the error track and stay there: the following steps skip themselves. There's nothing to check in between. It's the same railway-oriented programming I already talked about in the DevSweep series, now packaged to go.

Where Result becomes addictive is in real cases. A sequence of fallible operations you want to treat as one:

c#
Result<IReadOnlyList<int>, string> numbers =
    new[] { "1", "2", "3" }.Collect(Parse); // Success([1, 2, 3])

Collect walks the sequence and stops at the first failure. If all three values parse, you get a list; if one fails, you get that error and nothing else. All the "stop as soon as something goes wrong" logic disappears from your code and lives in a single place.

And when async enters the picture, which in a real application it always does, the combinators are still there:

c#
Result<string, string> name = await Result<int, string>.Success(1)
    .BindAsync(LoadUserAsync)      // an async step that can fail
    .TapAsync(AuditAsync)          // a side effect, keeping the value
    .MapAsync(user => user.Name);  // transform the success value

BindAsync, MapAsync and TapAsync work both on Result<...> and on Task<Result<...>>. That means you can chain the whole pipeline without a single intermediate await cluttering the expression. The detail seems minor until you write your tenth pipeline and realize you haven't had to unwrap anything by hand.

Either<TLeft, TRight>: two types, one decision

Either is Result's generalist cousin, and the last monad I added when extracting the library. It represents a value that is one of two possible types. By functional-programming convention it's right-biased: Right is the happy path and Left the alternative, so Map and Bind operate on Right and let Left pass through untouched.

c#
Either<string, int> right = Either<string, int>.FromRight(42);
Either<string, int> left  = Either<string, int>.FromLeft("boom");

// Map transforms the Right; the Left passes through
Either<string, string> mapped = right.Map(x => x.ToString());

// MapLeft transforms the Left; the Right passes through
Either<int, int> mappedLeft = left.MapLeft(e => e.Length);

// Swap flips the two tracks
Either<int, string> swapped = right.Swap(); // FromLeft(42)

So why Either if I already have Result? Because not everything "else" is an error. Sometimes both branches are legitimate: a value coming from the cache or from the database, a response that is text or number, a flow that forks. Result pushes you to read Left as "failure"; Either doesn't judge. Having both lets you pick the right semantics in each case instead of forcing everything into "success/error".

The design decision that holds it all up: readonly record struct

If you open any of the monads, you always find the same signature:

c#
public readonly record struct Either<TLeft, TRight>
{
    private readonly TLeft? left;
    private readonly TRight? right;
    // ...
}

There are several decisions here worth commenting on.

They're **struct**, not **class**. A monad is a razor-thin wrapper around a value. If it were a class, every Option or every Result would be a heap allocation, and suddenly the cost of "being elegant" is paid in garbage-collector pressure. As a struct, they live on the stack and the wrapper is practically free. Functional programming with no performance toll.

They're **readonly**. Once built, a monad doesn't change. This isn't functional dogma for dogma's sake: immutability is what makes Map and Bind always return something new instead of mutating what you had, and it's what makes reasoning about a pipeline possible.

They're **record**. You get value equality for free. Two Option<int>.Some(42) are equal, and that —as you'll see— turns out to be exactly what you need for testing.

And a small but deliberate detail: improper access isn't ignored, it's punished.

c#
public TRight Right => IsRight
    ? right!
    : throw new InvalidOperationException("Cannot access Right of a Left value");

If you try to read the Right of a Left, you blow up immediately. No silently returning a default that explodes three layers up. The correct path is to use Match; the dangerous shortcut tells you to your face.

LINQ sugar: the monads speak your language

C# has a beautiful query syntax that almost nobody uses outside collections. But from ... select is nothing more than SelectMany and Select with a nice face. So I implemented them for the monads:

c#
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)

This isn't a trick; it's exactly the same composition of Bind and Map as before, but read top to bottom like a recipe. If either step were Left, the entire result would be Left and the select wouldn't even run. The error track again, now disguised as a query.

Under the hood it's almost disappointingly simple:

c#
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)));

Every from/select in the world boils down to a Bind and a Map. That's the beauty of monads: once you have the two operations, the rest is decoration.

How do I know these are real monads?

I could have stopped at "it compiles and the examples work". But a monad isn't just any type with a method called Bind. It's a type that satisfies three laws: left identity, right identity and associativity. If your Bind doesn't satisfy them, you have something that looks like a monad and will break right when you stop looking.

So I tested them. One by one, with TUnit:

c#
[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);
}

Remember I made them record? Here's the payoff: IsEqualTo compares by value, so the law is written as what it is —"these two ways of chaining give the same result"— without a single manual unwrap. The design decision and the test fit like two pieces designed together. Because they were.

Beyond the three laws, the tests cover that Map is consistent with Bind, that Match picks the right branch and, above all, that short-circuiting works: that a Left truly ignores every Map, Bind, BindAsync and MapAsync you throw at it. Because the promise of the error track is worth nothing if it isn't verified.

So why not LanguageExt?

It's the inevitable question, so I'll answer it head-on. LanguageExt exists, has more than 7,000 stars, has years of development behind it and is, without question, an impressive piece of engineering. It brings Option, Either, Validation, Fin, Try, the Reader/Writer/State monads, its own immutable collections (Seq, Map, Lst...), IO effects, parser combinators, simulated higher-kinded types and a traits system with more than twenty abstractions. It does everything.

And that "it does everything" is exactly why I didn't use it.

It's not that LanguageExt is worse; it's that it solves a different problem. LanguageExt asks you to convert to its way of thinking: to learn its dialect, its collections, its traits system. In exchange it gives you a complete functional ecosystem. It's an architecture decision for a whole project, not a dependency you add lightly.

I didn't need to convert DevSweep to the functional paradigm. I needed three types that made null and errors explicit, that read like normal C# and that anyone on the team could understand in five minutes without opening a tutorial. Pulling in the whole of LanguageExt to use Option and Result is like installing an operating system to open a PDF.

That's the rule I set myself: SharpMonads.Core has to fit in your head. If using a monads library requires you to study the monads library, something has gone wrong. If your project truly lives and breathes functional programming, use LanguageExt without hesitation. If you just want to stop returning null, stick with something you can read end to end in one sitting.

The plumbing detail: publishing without secrets

A note outside the code, because it was the missing piece to turn "a DevSweep folder" into "a real package". The package is published to NuGet from GitHub Actions using Trusted Publishing with OIDC: no uploading an API key as a repo secret that sooner or later leaks or expires. GitHub identifies itself to NuGet with an ephemeral token, and the workflow runs the tests before publishing. If the tests don't pass, there's no release. It sounds obvious put that way, but it's the kind of thing you forget until the day you publish something broken.

What I take away from this project

SharpMonads.Core started as throwaway code inside another project, and has become the dependency I now drag into all the others. That trajectory —from folder to package— is probably the most useful lesson: the best small libraries aren't designed, they're distilled. They appear when you catch yourself copying the same code a second time.

Three ideas keep going around in my head:

  • If you copy it a second time, extract it. Copy-paste between projects is the most honest sign that something deserves to live on its own.

  • Elegance doesn't have to cost performance. readonly record struct is proof that you can have functional composition without paying an allocation per step.

  • Scope is a design decision. Saying no to LanguageExt's other 50 features was as important as saying yes to Bind. A library that fits in your head is a library people use.

You have the code on GitHub and the package on NuGet:

bash
dotnet add package SharpMonads.Core

The next stop is to keep distilling: Validation to accumulate errors instead of stopping at the first one is the clearest candidate, because it's exactly what I'm already starting to miss. But that'll be another story.

See you in the next post!