A fully-serializable, F#-like result pattern for C#.
Aigamo.Results models success and failure as a single Result<T, TError> value — a closed record with exactly two cases, Ok and Error — and gives you a full set of functional operations (Map, FlatMap, Fold, Combine, …) to transform and compose results without ever throwing. Unlike many result types, it round-trips cleanly through System.Text.Json, so a Result can cross a serialization boundary — an API response, a message queue, a cache — and come back intact.
Result<T, TError>with a strongly-typed error channel (not just a message or an exception)- A closed hierarchy (
Ok/Error) — the compiler knows there are no other cases - Fully serializable with
System.Text.Json(polymorphic, round-trips by value) - Rich functional API:
Map,MapError,FlatMap,FlatMapError,Fold,Tap,GetOr,Combine,Merge,MapEach,Flatten,Validate,Contains, and more - Async all the way: every operation has
Task-lifted overloads, so sync and async steps chain seamlessly Unitfor value-less results (Result<Unit, TError>)- Implicit conversions: return a value or an error directly and let it become a
Result<T, TError> - An optional analyzer to enforce implicit- or explicit-construction style across a project
dotnet add package Aigamo.ResultsTip: To share the reference and drop the per-file
using, add it toDirectory.Build.propswith a globalUsing:<Project> <ItemGroup> <PackageReference Include="Aigamo.Results" /> <Using Include="Aigamo.Results" /> </ItemGroup> </Project>
using Aigamo.Results;
Result<int, string> ok = Result.Ok<int, string>(42);
Result<int, string> error = Result.Error<int, string>("something went wrong");A value or an error also converts implicitly, so you can often drop the factory call:
Result<int, string> ok = 42; // -> Ok(42)
Result<int, string> error = "something went wrong"; // -> Error("something went wrong")A function that can fail returns its outcome instead of throwing:
static Result<int, string> Parse(string s) =>
int.TryParse(s, out var value)
? value // implicit -> Ok(value)
: $"'{s}' is not a number"; // implicit -> Error(...)Note: implicit conversion is unavailable when
TandTErrorare the same type (Result<string, string>) — the two conversions are ambiguous (CS0457). Use the explicitResult.Ok/Result.Errorfactories there.
Fold collapses both cases into a single value:
string message = Parse("42").Fold(
onOk: value => $"Parsed {value}",
onError: error => $"Failed: {error}"
);Match gives you the case objects directly, and helpers like GetOr, Contains, and Validate cover common checks:
int value = Parse("nope").GetOr(ifError: _ => 0); // 0
bool isFortyTwo = Parse("42").Contains(42); // true
bool isPositive = Parse("42").Validate(x => x > 0); // true// Map the success value; Error passes through untouched.
Result<string, string> label = Parse("42").Map(x => $"#{x}");
// FlatMap chains another fallible step, short-circuiting on the first Error.
Result<int, string> checked = Parse("42").FlatMap(x =>
x >= 0 ? Result.Ok<int, string>(x) : Result.Error<int, string>("negative"));
// MapError transforms the error channel.
Result<int, MyError> typed = Parse("42").MapError(msg => new MyError(msg));
// Tap runs a side effect on success without changing the result.
Parse("42").Tap(x => Console.WriteLine(x));Combine several results into a tuple (the first Error wins), or collapse a sequence:
// Combine: accumulate success values into a growing tuple.
Result<(int, int, int), string> combined = Parse("1")
.Combine(_ => Parse("2"))
.Combine(_ => Parse("3"));
// Merge: IEnumerable<Result<T, E>> -> Result<T[], E>
Result<int[], string> merged = new[] { "1", "2", "3" }.Select(Parse).Merge();Every operation ships Task-lifted overloads, so you can freely mix synchronous and asynchronous steps in one chain — the Task is threaded through for you:
Result<User, string> result = await LoadUserAsync(id) // Task<Result<User, string>>
.FlatMap(user => ValidateAsync(user)) // async step
.Map(user => user.Name) // sync step
.TapError(error => logger.LogWarning(error)); // async source, sync actionFor each operation you get the overloads for a Task source, an async selector, and both at once.
Result<T, TError> is annotated for System.Text.Json polymorphic serialization and round-trips by value:
using System.Text.Json;
var ok = Result.Ok<int, string>(42);
string json = JsonSerializer.Serialize(ok);
// {"$type":"Ok","ResultValue":42}
var back = JsonSerializer.Deserialize<Result<int, string>>(json);
// back == ok (true)An Error serializes as {"$type":"Error","ErrorValue":...}. No custom converter or configuration required.
Whether to write Result.Ok(value) explicitly or lean on the implicit conversion is a matter of taste — so the package ships an analyzer that lets a project pick one and enforce it. Set the policy in .editorconfig:
[*.cs]
# choose one:
aigamo_results_conversion_style = implicit # or: explicitimplicit→ ARS001 flags an explicitResult.Ok/Result.Errorcall where the value could convert implicitly, and the code fix removes the wrapper.explicit→ ARS002 flags reliance on the implicit conversion, and the code fix wraps the value inResult.Ok/Result.Error.
When the key is unset, neither rule fires. Both default to warning severity and can be tuned per rule:
dotnet_diagnostic.ARS001.severity = suggestionARS001 is never reported for Result<T, T>, where implicit conversion is ambiguous.
Result<T, TError> is a closed record whose Ok/Error cases are nested with a private constructor, so the hierarchy cannot be extended elsewhere. The exhaustive Match method is generated by Aigamo.MatchGenerator, and the Task-lifted overloads are emitted by an internal [GenerateAsyncOverloads] source generator — the hand-written code only defines the synchronous core of each operation.
Aigamo.Results is inspired by Nut.Results. The goal here is a result type that keeps a strongly-typed error channel and serializes cleanly, with the functional surface generated rather than hand-maintained.
MIT