Svan.Monads
2.0.3
dotnet add package Svan.Monads --version 2.0.3
NuGet\Install-Package Svan.Monads -Version 2.0.3
<PackageReference Include="Svan.Monads" Version="2.0.3" />
<PackageVersion Include="Svan.Monads" Version="2.0.3" />
<PackageReference Include="Svan.Monads" />
paket add Svan.Monads --version 2.0.3
#r "nuget: Svan.Monads, 2.0.3"
#:package Svan.Monads@2.0.3
#addin nuget:?package=Svan.Monads&version=2.0.3
#tool nuget:?package=Svan.Monads&version=2.0.3
Svan.Monads
This library adds some common monads to C#, enabling "Railway Oriented Programming" and functional programming styles. It includes the Option<T>, Result<TError, TSuccess>, Try<TSuccess>, and Either<TLeft, TRight> monads, along with extension methods for fluent chaining, async, and error handling.
Installation
dotnet add package Svan.Monads
Code Examples
- Using the Option monad to manipulate a stream of integers
- Using the Result monad to compose data from different API calls
- Using the Result monad with a OneOf type as error
Breaking changes in version 2.x
The library no longer depends on OneOf. The monads now inherit from a built-in Union<TLeft, TRight> base class that is discriminated using Left<T> and Right<T> wrapper types.
OneOfBase is no longer the base class
The Match and Switch methods from OneOf (which received wrapper types like Error<T>, Success<T>, Some<T>) are replaced by Fold, which receives the unwrapped values directly.
// Option: Match/Switch took None and Some<T>
- option.Match(
- none => "nothing",
- some => $"got {some.Value}");
+ option.Fold(
+ () => "nothing",
+ value => $"got {value}");
// Option: void side-effects used Switch
- option.Switch(
- none => Console.WriteLine("nothing"),
- some => Console.WriteLine(some.Value));
+ option.Do(value => Console.WriteLine(value))
+ .DoIfNone(() => Console.WriteLine("nothing"));
// Result: Match/Switch took Error<TError> and Success<TSuccess>
- result.Match(
- error => $"failed: {error.Value}",
- success => $"got {success.Value}");
+ result.Fold(
+ error => $"failed: {error}",
+ success => $"got {success}");
// Result: void side-effects used Switch
- result.Switch(
- error => Console.WriteLine(error.Value),
- success => Console.WriteLine(success.Value));
+ result.Do(success => Console.WriteLine(success))
+ .DoIfError(error => Console.WriteLine(error));
Error<T>, Success<T> and Some<T> have been removed
These OneOf wrapper types are no longer needed. Union now uses Left<T> and Right<T> internally for discrimination, and all public APIs work with the unwrapped values directly.
- Option<int> option = new Some<int>(42);
+ Option<int> option = Option<int>.Some(42);
- Result<string, int> result = new Success<int>(42);
- Result<string, int> error = new Error<string>("fail");
+ var result = Result<string, int>.Success(42);
+ var error = Result<string, int>.Error("fail");
Result and Try no longer have implicit conversions
Construction now uses explicit factory methods instead of implicit operators.
- Result<string, int> result = 42;
- Result<string, int> error = "something went wrong";
+ var result = Result<string, int>.Success(42);
+ var error = Result<string, int>.Error("something went wrong");
- Try<int> tried = 42;
- Try<int> failed = new Exception("boom");
+ var tried = Try<int>.Success(42);
+ var failed = Try<int>.Exception(new Exception("boom"));
The Option Monad
The Option<T> monad represents a value that may or may not exist. It is modeled after F#'s Option Type and is functionally similar to Haskell's Maybe Monad.
Constructing
// Using factory methods
var some = Option<int>.Some(42);
var none = Option<int>.None();
// Using the non-generic static class
var some2 = Option.Some(42);
var none2 = Option.None<int>();
// Using the extension method (null becomes None for reference types)
var fromValue = 42.ToOption();
Monadic operations
var option = Option.Some(42);
var result = option
.Filter(i => i > 10)
.Map(i => i * 2)
.Bind(i => i % 2 == 0
? Option<string>.Some($"even: {i}")
: Option<string>.None())
.Do(Console.WriteLine);
Unboxing
var option = Option.Some(42);
// Fold with handlers for both cases
string message = option.Fold(
() => "nothing here",
value => $"got {value}");
// Get the value or a default
int value = option.DefaultWith(() => -1);
// Get the value or throw
int certain = option.OrThrow();
// Convert to Result
Result<string, int> result = option.ToResult(() => "was none");
The Result Monad
The Result<TError, TSuccess> monad represents an operation that can either succeed with a TSuccess value or fail with a TError value. It enables railway-oriented error handling and readable data transformation pipelines.
Constructing
// Using factory methods
var success = Result<string, int>.Success(42);
var error = Result<string, int>.Error("something went wrong");
// Using the non-generic static class
var success2 = Result.Success<string, int>(42);
var error2 = Result.Error<string, int>("something went wrong");
// Using extension methods
var success3 = 42.ToSuccess<string, int>();
var error3 = "something went wrong".ToError<string, int>();
Monadic operations
Result<string, int> Divide(int number, int by)
=> by == 0
? Result<string, int>.Error("division by zero")
: Result<string, int>.Success(number / by);
var result = Divide(12, 2)
.Bind(n => Divide(n, 2))
.Map(n => n * 2)
.MapError(e => e.ToUpper())
.Do(n => Console.WriteLine($"Result: {n}"))
.DoIfError(e => Console.WriteLine($"Error: {e}"));
Unboxing
var result = Result<string, int>.Success(42);
// Fold with handlers for both cases
string message = result.Fold(
error => $"failed: {error}",
success => $"got {success}");
// Get the value or a default derived from the error
int value = result.DefaultWith(error => -1);
// Get the value or throw
int certain = result.OrThrow();
// Convert to Option (error becomes None)
Option<int> option = result.ToOption();
The Try Monad
The Try<TSuccess> monad is a specialization of Result<Exception, TSuccess> that catches exceptions automatically via its Try.Catching constructor.
Constructing
// Catching exceptions automatically
var tried = Try.Catching(() => int.Parse("42"));
var failed = Try.Catching<int>(() => throw new Exception("boom"));
// Using factory methods
var success = Try<int>.Success(42);
var error = Try<int>.Exception(new InvalidOperationException("oops"));
// Using the non-generic static class
var success2 = Try.Success(42);
var error2 = Try.Exception<int>(new InvalidOperationException("oops"));
// Using extension methods
var success3 = 42.ToSuccess<int>();
var error3 = new InvalidOperationException("oops").ToException<int>();
Monadic operations
var result = Try.Catching(() => "42")
.Map(int.Parse)
.Bind(n => n > 0
? Try<int>.Success(n)
: Try<int>.Exception(new Exception("must be positive")))
.Do(n => Console.WriteLine($"Result: {n}"))
.DoIfError(ex => Console.WriteLine($"Error: {ex.Message}"));
// MapCatching and BindCatching catch exceptions thrown by the callback
var safe = Try.Catching(() => "hello")
.MapCatching(s => int.Parse(s))
.BindCatching(n => Try<string>.Success(n.ToString()));
Unboxing
Try<TSuccess> inherits all unboxing methods from Result<Exception, TSuccess>:
var tried = Try.Catching(() => int.Parse("42"));
string message = tried.Fold(
ex => $"failed: {ex.Message}",
value => $"got {value}");
int value = tried.DefaultWith(ex => -1);
int certain = tried.OrThrow();
The Either Monad
The Either<TLeft, TRight> monad is a general-purpose discriminated union where both sides carry meaning. It is right-biased for chainable pipelines, but without the opinionated error/success semantics of Result. Use Either when both outcomes are valid domain values.
Constructing
// Using factory methods
var left = Either<string, int>.FromLeft("heads");
var right = Either<string, int>.FromRight(42);
// Using the non-generic static class
var left2 = Either.FromLeft<string, int>("heads");
var right2 = Either.FromRight<string, int>(42);
// Using extension methods
var left3 = "heads".ToLeft<string, int>();
var right3 = 42.ToRight<string, int>();
Monadic operations
var either = Either<string, int>.FromRight(21);
var result = either
.Map(n => n * 2)
.Bind(n => n > 0
? Either<string, int>.FromRight(n)
: Either<string, int>.FromLeft("non-positive"))
.MapLeft(msg => msg.ToUpper())
.Do(n => Console.WriteLine($"Right: {n}"))
.DoIfLeft(msg => Console.WriteLine($"Left: {msg}"));
Unboxing
var either = Either<string, int>.FromRight(42);
// Fold with handlers for both cases
string message = either.Fold(
left => $"left: {left}",
right => $"right: {right}");
// Get the right value or a default derived from the left value
int value = either.DefaultWith(left => -1);
// Get the right value or throw
int certain = either.OrThrow();
// Swap left and right sides
Either<int, string> swapped = either.Swap();
// Convert to Option (left becomes None) or Result (left becomes Error)
Option<int> option = either.ToOption();
Result<string, int> result = either.ToResult();
Async Support
Support of async workflows is added as a small set of extension methods. Sync operations work naturally after an await, and only callbacks that are themselves async need async-specific methods.
Awaiting then chaining sync operations
When an async function returns an Option<T>, Result<TError, T>, Try<T>, or Either<TLeft, TRight>, you can await it and then chain any sync operation as usual:
async Task<Option<int>> FindUserId(string username) { ... }
var result = (await FindUserId("malin"))
.Map(id => id * 2)
.Filter(id => id > 0)
.DefaultWith(() => -1);
BindAsync and MapAsync
When the callback itself is async, BindAsync and MapAsync enable fluent chaining without intermediate awaits:
async Task<Option<string>> FindUserEmail(int userId) { ... }
async Task<string> NormalizeEmail(string email) { ... }
var result = await FindUserId("malin")
.BindAsync(id => FindUserEmail(id))
.MapAsync(email => NormalizeEmail(email));
These work the same way on Result, Try, and Either:
async Task<Result<string, int>> ParseUserId(string input) { ... }
async Task<Result<string, string>> LookupUsername(int userId) { ... }
var greeting = (await ParseUserId("42")
.BindAsync(id => LookupUsername(id)))
.Map(name => $"Welcome, {name}!")
.DefaultWith(error => $"Error: {error}");
async Task<Either<string, int>> ParseId(string input) { ... }
async Task<Either<string, string>> LookupName(int id) { ... }
var greeting = (await ParseId("42")
.BindAsync(id => LookupName(id)))
.Map(name => $"Welcome, {name}!")
.DefaultWith(left => $"Left: {left}");
Sequence
When you have a sync monad and Map it with an async function, you get e.g. Option<Task<T>>. Sequence flips this into Task<Option<T>> so you can await it:
Option<string> email = Option<string>.Some(" ALICE@EXAMPLE.COM ");
var result = await email
.Map(e => NormalizeEmail(e))
.Sequence();
This also works on Result, Try, and Either, skipping the async work when in the error/left state.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
## What's Changed
* Upgrade to .NET 10 and enrich XML documentation with examples by @svan-jansson in https://github.com/svan-jansson/Svan.Monads/pull/10
**Full Changelog**: https://github.com/svan-jansson/Svan.Monads/compare/v2.0.2...v2.0.3