Monads
Learn about monads and why they are important to use in your code
What is a monad?
Monads sound intimidating, but in practice, they're just a way to chain operations while carrying some context.
In C#, we usually deal with values directly. Monads, on the other hand, wrap values and give you a consistent way to work with them, even when things get messy.
Monads provide a structured way to represent chained computations, especially ones that might fail, return nothing, or produce side effects
Monads are used to encode optional values and fallible computations without resorting to null
or try/catch
. The library implements two monads:
Option<T>
for "maybe there's a value"Result<T, E>
for "this might succeed or fail"
A practical definition
A monad is a type that wraps a value and provides consistent semantics for composing operations on it.
A monad must:
let you wrap a value, e.g.
Some
,Ok
let you chain operations, e.g.
Map
,AndThen
preserve context, e.g. whether it's missing or failed
Why use monads?
When writing C# code without monads, we resort to:
Returning
null
when there is a valid business rule for the absence of a valueThrowing
Exceptions
for every instance of an error, even if the error is a valid business ruleWrapping methods inside
try/catch
blocks just to log theException
and then re-throw itWriting
null
guard clauses everywhereWriting
if/else
blocks orswitch
blocks in order to handle branching logic
Take for example the below code:
Request? PrepareRequest(decimal input); // can return null or throw an exception
Response DoWork(Request request); // can still return null or throw
Response SaveData(decmial? input)
{
if (input is null) {
return Fallback();
}
try
{
Request? request = PrepareRequest(input.Value);
return request is not null
? DoWork(request)
: Fallback();
}
catch (FailedToPrepareRequestException ex) // An exception for a valid use case
{
_logger.LogWarning(ex, "Failed to prepare request");
return Fallback();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to save data");
throw;
}
}
Response response = SaveData(10); // can be response or throw an exception
string output = response?.Message ?? "Something went wrong"; // errors potentially lost
Lets write the same code again but this time using monads:
Option<Request> PrepareRequest(decimal input); // never throws, may be some or none
Result<Response, Error> DoWork(Request request); // may be ok or error
Result<Response, Error> SaveData(Option<decimal> input) =>
input.FlatMap(PrepareRequest) // If input is Some, PrepareRequest
.Map(DoWork) // If PrepareRequest is Some, DoWork
.Transpose() // Turn the Option<Result> into a Result<Option>
.InspectErr(error => _logger.LogWarning(error)) // If Err, log a warning
.Map(response => response.UnwrapOrElse(Fallback)); // If Ok, Get the Response if the Option is Some, or the Fallback
string output = SaveData(Option.Some(10.0m)).Match(
onOk: response => response.Message,
onErr: error => error.Message // always the first error encountered in the pipeline
);
Monads enable you to build a pipeline of transformations that can short-circuit cleanly. No conditionals, no defensive null-checking, no local variables spread everywhere.
Example: The railway tracks
Imagine your program as a train moving along a railway track. Each step of your logic is a station. You want the train to move from station to station - loading data, transforming it, performing checks, etc, but things can go wrong.
The passenger might be missing
A validation might fail
A file might not be found, or an input might be malformed
Without monads (derailment everywhere)
In regular C# code, errors or missing values derail the train. You get thrown into:
null checks everywhere
try/catch blocks scattered across your code
unpredictable paths:
some functions might return
null
,some throw exceptions,
some return data
It becomes hard to know what to expect, and even harder to safely chain operations together.
With monads (safe, predictable tracks)
Monads like Option<T>
and Result<T,E>
put your logic on two parallel railway tracks:
🚆 success track - your train keeps moving smoothly
🚧 failure or none track - your train gets rerouted safely to a dead-end, without crashing
The train never jumps tracks randomly, it stays on one track or the other, and every station (function) is built to handle both cases.
Option<string> email = GetUserById(id)
.Map(u => u.Profile)
.Map(p => p.Email);
If the user doesn't exist, or the profile is missing, the train never crashes. It just stops safely at None
. You can later decide what to do (show an error message, fall back to defaults, etc).
string displayEmail = email.Match(
some => some,
none => "[Not Provided]"
);
Last updated
Was this helpful?