High-performance functional Result types for .NET with explicit error handling. Features: Railway Oriented Programming, exhaustive pattern matching, Result with multiple specific error types (Result<TSuccess, TError1, TError2, ...>), source-generated semantic Match extensions, and TryGetError<T> for type-based error handling. No exceptions, zero allocations.
$ dotnet add package ResultlyHigh-performance functional Result types for .NET — explicit error handling without exceptions.
Resultly implements Railway Oriented Programming, providing type-safe error handling through discriminated unions. It eliminates the need for try-catch blocks and makes error paths explicit in your type system.
🚀 Zero allocation — struct-based design with aggressive inlining
🔒 Type-safe — closed type hierarchy with exhaustive pattern matching
🛤️ Railway Oriented Programming — compose operations that short-circuit on errors
⚡ Async/await support — full async variants of all operations
📦 Collection operations — Traverse, Sequence, Combine for working with multiple Results
🧩 Monadic — Map, Bind, Then, and other functional operators
🎯 Validation — Ensure and EnsureAll for domain validation
🔄 Error recovery — OrElse, Recover, and fallback mechanisms
🎭 Multiple Error Types — Result with up to 4 specific error types visible in signature
🧾 Canonical ResultError — Result<TSuccess> facade with structured errors (message/code/metadata)
🛡️ Diagnostics Guards — detect uninitialized Result<T> instances with sync/Task/ValueTask helpers
🏷️ Semantic Match — Source-generated semantic parameter names with sync and async overloads (works for Result and Task<Result> across every arity)
🔍 Type-Based Error Extraction — TryGetError<T>() for extracting errors by type
✨ Convenience helpers — AnyError / AnyErrorAsync and TryGetAnyError to quickly return or inspect any error from multi-error Result types without writing exhaustive Match at call sites
🔀 Pure Discriminated Unions — Either types for type-safe unions without success/error semantics, with descriptive onFirst/onSecond handlers and optional semantic names
Comprehensive — extensive XML documentation and 300+ tests with high code coverage
✅ 4-arity async-fluent MapErrors — full parity for 4-error Result types: you can fluently call WithError1Async/WithError2Async/WithError3Async/WithError4Async in any order to build mapping pipelines and then call ToEitherAsync(); note the mapping order is determined by the final builder shape (the builder arranges mapped slots in the resulting Either in a deterministic order based on which With* was applied).
🧰 MatchErrors convenience helpers — thin overloads such as result.MatchErrors(onError1, onError2) provide a compact way to map a selected subset of error slots directly to an Either<...> without manually composing the full fluent builder chain. Use these for ergonomic, concise error mappings in call sites and examples.
dotnet add package Resultly
Supports: .NET 9.0 | .NET 8.0 (LTS) | .NET Standard 2.1
An end-to-end sample console app lives in Examples/Resultly.ExampleApp. It is part of the solution for easy F5 debugging, yet it is marked as IsPackable=false so it never ships with the NuGet package. The app walks through synchronous/async pipelines, semantic matching, collection helpers, and pure Either unions using the new semantic match helpers. Build or run it directly to exercise the public surface:
dotnet run --project Examples/Resultly.ExampleApp/Resultly.ExampleApp.csproj
If you add new APIs, wiring them into this sample helps the compiler catch integration issues early.
using Resultly;
// Create Results using implicit conversions
Result<int, string> success = 42;
Result<int, string> failure = "Not found";
// Pattern matching
var message = success.Match(
onSuccess: value => $"Got {value}",
onError: error => $"Error: {error}"
);
Result<TSuccess> is a thin wrapper over Result<TSuccess, ResultError> that gives you a structured error payload (message, optional code, optional metadata) without having to declare a custom error type.
// Successful result
var quotient = Result<int>.Success(42);
// Structured failure (message + code + metadata)
var failure = Result<int>.Failure(
message: "Division by zero",
code: "DIVIDE_BY_ZERO",
metadata: new Dictionary<string, string?> { ["denominator"] = "0" }
);
// Semantic match works for Result<T>
var message = failure.MatchSemantic(
onSuccess: value => $"Quotient: {value}",
onResultError: error => $"Calculation failed ({error.Code ?? "ERR"}): {error.Message}"
);
// Async overloads are available too
var audit = await failure.MatchSemanticAsync(
async value => { await LogAsync(value); return "ok"; },
async error => { await LogErrorAsync(error); return "fail"; }
);
// Metadata helpers let you update context without rebuilding dictionaries
var traceId = Guid.NewGuid().ToString("N");
var correlationId = traceId.ToUpperInvariant();
var enriched = failure.AddMetadata("operation", "Divide");
var replaced = failure
.AddMetadataRange(new Dictionary<string, string?> { ["traceId"] = traceId })
.RemoveMetadata("operation")
.WithMetadata(
new Dictionary<string, string?> { ["correlationId"] = correlationId },
StringComparer.OrdinalIgnoreCase
);
// Default(Result<T>) surfaces ResultError.Uninitialized for easy guard clauses
if (!default(Result<int>).IsInitialized())
{
Console.WriteLine("Result<int> was not initialized before use.");
}
// ThrowIfUninitialized() provides a straightforward guard for API entry points
var result = default(Result<int>);
result.ThrowIfUninitialized(); // throws InvalidOperationException with a descriptive message
// ValueTask overloads are available when the result is produced asynchronously
var pending = new ValueTask<Result<int>>(Result<int>.Success(5));
await pending.ThrowIfUninitialized();
Define Results with multiple explicit error types:
public record User(string Name, string Email);
public record NotFoundError(string Message);
public record DatabaseError(string Message);
public record ValidationError(string Message);
// All error types visible in the signature
public Result<User, NotFoundError, DatabaseError> GetUser(int id)
{
if (id < 0)
return new ValidationError("Invalid ID"); // Won't compile - not in signature!
if (!_db.Exists(id))
return new NotFoundError($"User {id} not found");
try
{
return _db.GetUser(id);
}
catch
{
return new DatabaseError("Connection failed");
}
}
// Use semantic Match - generated automatically!
var result = GetUser(42);
result.MatchSemantic(
onSuccess: user => Console.WriteLine($"Hello, {user.Name}"),
onNotFoundError: err => Console.WriteLine($"404: {err.Message}"),
onDatabaseError: err => Console.WriteLine($"DB Error: {err.Message}")
);
// Async version preserves semantic naming across awaits
var greeting = await result.MatchSemanticAsync(
async user =>
{
await emailService.SendWelcome(user);
return $"Sent email to {user.Email}";
},
async notFound =>
{
await telemetry.TrackMiss(notFound.Message);
return $"Missing {notFound.Message}";
},
async dbError =>
{
await telemetry.TrackFailure(dbError.Message);
return $"Database issue {dbError.Message}";
}
);
// Or extract errors by type
if (result.TryGetError<DatabaseError>(out var dbError))
{
_logger.LogError("Database issue: {Message}", dbError.Message);
// Retry logic here
}
Benefits:
TryGetError<T>()Sometimes you need type-safe unions without success/error semantics. Either types provide pure discriminated unions where all cases are semantically equal:
using Resultly;
// Either2: A value that is one of two types
public record UserInput();
public record AdminInput();
public Either<UserInput, AdminInput> ParseInput(string role, string data)
{
return role == "admin"
? Either<UserInput, AdminInput>.Second(new AdminInput())
: Either<UserInput, AdminInput>.First(new UserInput());
}
// Pattern match all cases
var result = ParseInput("user", "data");
result.Match(
onFirst: userInput => ProcessUserInput(userInput),
onSecond: adminInput => ProcessAdminInput(adminInput)
);
// Or use MatchSemantic to stick to type-based names
result.MatchSemantic(
onUserInput: user => ProcessUserInput(user),
onAdminInput: admin => ProcessAdminInput(admin)
);
Either3/4/5 for Multiple Types:
// Payment methods
public record CreditCard(string Number);
public record PayPal(string Email);
public record BankTransfer(string IBAN);
public record Cryptocurrency(string Wallet);
public Either<CreditCard, PayPal, BankTransfer, Cryptocurrency> GetPaymentMethod()
{
// Return any of the four types
return Either<CreditCard, PayPal, BankTransfer, Cryptocurrency>
.Third(new BankTransfer("DE89370400440532013000"));
}
// Exhaustive matching required
paymentMethod.Match(
onFirst: cc => ProcessCreditCard(cc),
onSecond: pp => ProcessPayPal(pp),
onThird: bt => ProcessBankTransfer(bt),
onFourth: crypto => ProcessCrypto(crypto)
);
Type Extraction:
// Try to extract a specific type
if (paymentMethod.TryPick<PayPal>(out var paypal))
{
Console.WriteLine($"PayPal: {paypal.Email}");
}
// Or get all values as tuples
var (card, paypal, bank, crypto) = paymentMethod.Get();
// Only one will be non-null
Practical Use Cases:
// 1. Configuration sources
public Either<JsonConfig, XmlConfig, YamlConfig> LoadConfig(string path)
{
var ext = Path.GetExtension(path);
return ext switch
{
".json" => Either<JsonConfig, XmlConfig, YamlConfig>.First(ParseJson(path)),
".xml" => Either<JsonConfig, XmlConfig, YamlConfig>.Second(ParseXml(path)),
".yaml" => Either<JsonConfig, XmlConfig, YamlConfig>.Third(ParseYaml(path)),
_ => throw new NotSupportedException()
};
}
// 2. Protocol messages
public record HttpRequest();
public record WebSocketMessage();
public record GrpcCall();
public Either<HttpRequest, WebSocketMessage, GrpcCall> ReceiveMessage()
{
// Return the appropriate message type
}
// 3. State machines
public record Idle();
public record Processing();
public record Completed();
public record Failed();
public Either<Idle, Processing, Completed, Failed> GetCurrentState()
{
// Return current state without success/error connotation
}
Key Differences from Result:
| Feature | Result | Either |
|---|---|---|
| Semantics | Success vs. Error | Equal alternatives |
| Short-circuiting | Operators stop on Error | No special behavior |
| Use case | Error handling | Type unions, state machines |
| Pattern matching | onSuccess/onError | onFirst/onSecond/onThird... |
| Operators | Map, Bind, Then, etc. | Match, TryPick, Get |
When to use Either vs Result:
Chain operations that automatically short-circuit on errors:
var result = GetUserEmail(userId)
.Bind(ValidateEmail)
.Map(email => email.ToLower())
.Bind(SendNotification);
var result =
from user in GetUser(id)
from profile in GetProfile(user.ProfileId)
where profile.IsActive
select new UserViewModel(user, profile);
Chain operations that automatically short-circuit on the first error:
var result = ValidateInput(input)
.Then(ParseValue)
.Ensure(x => x > 0, _ => "Must be positive")
.Map(x => x * 2)
.Then(SaveToDatabase)
.MapError(error => $"Pipeline failed: {error}");
// Handle the result
result.Match(
onSuccess: id => Console.WriteLine($"Saved with ID: {id}"),
onError: error => Console.WriteLine($"Error: {error}")
);
// Implicit conversions
Result<int, string> success = 42;
Result<int, string> failure = "error";
// Static factory methods
Result<int, string>.Success(42)
Result<int, string>.Failure("error")
// Lift regular values
Result<int, string>.FromValue(42)
Transform success values while preserving errors:
Result<int, string> result = 42;
var doubled = result.Map(x => x * 2); // Ok(84)
// Async variant
var asyncResult = await result.MapAsync(async x => await ComputeAsync(x));
Chain Result-returning functions without nesting:
Result<string, string> GetUserId(string email) { /* ... */ }
Result<User, string> LoadUser(string userId) { /* ... */ }
var user = GetUserId("user@example.com")
.Bind(LoadUser);
// Async variant
var asyncUser = await GetUserIdAsync(email)
.BindAsync(async id => await LoadUserAsync(id));
Railway-style continuation (alias for Bind with clearer intent):
var result = ParseInput(input)
.Then(Validate)
.Then(Transform)
.Then(Persist);
// Async variant
var asyncResult = await ParseInputAsync(input)
.ThenAsync(ValidateAsync)
.ThenAsync(TransformAsync);
Validate and convert to error if predicate fails:
var result = Result<int, string>.Success(150)
.Ensure(x => x >= 0, _ => "Must be non-negative")
.Ensure(x => x <= 100, x => $"{x} exceeds maximum");
// EnsureAll: Apply multiple validations, collect all errors
var validated = result.EnsureAll(
(x => x >= 0, _ => "Must be non-negative"),
(x => x <= 100, _ => "Must not exceed 100"),
(x => x % 2 == 0, _ => "Must be even")
);
Exhaustively handle both cases:
// Match with return value
var output = result.Match(
onSuccess: value => ProcessValue(value),
onError: error => HandleError(error)
);
// Match with side effects (void)
result.Match(
onSuccess: value => Console.WriteLine(value),
onError: error => Console.Error.WriteLine(error)
);
// MatchSemantic for Result3/4/5 (source-generated)
result.MatchSemantic(
onSuccess: user => HandleSuccess(user),
onNotFoundError: err => HandleNotFound(err),
onDatabaseError: err => HandleDatabase(err)
);
// Async variant (Result or Task<Result>)
var summary = await result.MatchSemanticAsync(
async user => await BuildSummary(user),
async notFound => await HandleNotFoundAsync(notFound),
async dbError => await HandleDatabaseAsync(dbError)
);
// Extract specific error type from Result with multiple errors
if (result.TryGetError<DatabaseError>(out var dbError))
{
_logger.LogError("Database issue: {Message}", dbError.Message);
}
// For Either types
if (either.TryPick<PayPal>(out var paypal))
{
ProcessPayPal(paypal);
}
// Get all values as tuple (only one non-null)
var (first, second, third) = either.Get();
All operations have async variants:
var result = await ValidateAsync(input)
.ThenAsync(async x => await SaveAsync(x))
.MapAsync(async x => await TransformAsync(x));
Note: the canonical conversion APIs are ToEither and the MapErrors() builder + ToEither() terminal. A number of small convenience wrappers (for example MapToEither / MapSuccessToEither and a few thin MapToEitherE1E2/MapToEitherE2E3 helpers) were removed to keep the API surface focused. Use one of the following migration patterns:
result.ToEither(onError1) or await result.ToEitherAsync(onError1Async)result.ToEitherMap(OnFirstError: e1 => left, OnSecondError: e2 => right)result.MapErrors().WithError1(e1 => map1).WithError2(e2 => map2).ToEither()For ergonomic fluent chains the lightweight top-level forwarding helpers WithError1/WithError2/WithError3/WithSuccess still forward to MapErrors() so existing short-call call sites like result.WithError1(...).WithError3(...).ToEither() continue to work.
// Result<TSuccess, TError> - Single error type
Result<User, string> user = GetUser(id);
// Result<TSuccess, TError1, TError2> - Two error types
Result<User, NotFoundError, DatabaseError> user = GetUser(id);
// Result<TSuccess, TError1, TError2, TError3> - Three error types
Result<User, NotFoundError, DatabaseError, ValidationError> user = GetUser(id);
// Result<TSuccess, TError1, TError2, TError3, TError4> - Four error types
Result<User, NotFoundError, DatabaseError, ValidationError, TimeoutError> user = GetUser(id);
// Either<T1, T2> - Two alternatives
Either<JsonConfig, XmlConfig> config = LoadConfig();
// Either<T1, T2, T3> - Three alternatives
Either<JsonConfig, XmlConfig, YamlConfig> config = LoadConfig();
// Either<T1, T2, T3, T4> - Four alternatives
Either<CreditCard, PayPal, BankTransfer, Cryptocurrency> payment = GetPayment();
// Either<T1, T2, T3, T4, T5> - Five alternatives
Either<Idle, Processing, Completed, Failed, Cancelled> state = GetState();
// Result construction
var success = Result<int, string>.Success(42);
var failure = Result<int, string>.Failure("error");
// Implicit conversion
Result<int, string> r1 = 42; // Success
Result<int, string> r2 = "error"; // Failure
// Multi-error Result construction
var notFound = Result<User, NotFoundError, DatabaseError>.Error1(new NotFoundError());
var dbError = Result<User, NotFoundError, DatabaseError>.Error2(new DatabaseError());
// Either construction
var first = Either<string, int>.First("hello");
var second = Either<string, int>.Second(42);
// Multi-type Either
var json = Either<JsonConfig, XmlConfig, YamlConfig>.First(config);
var xml = Either<JsonConfig, XmlConfig, YamlConfig>.Second(config);
var yaml = Either<JsonConfig, XmlConfig, YamlConfig>.Third(config);
Combine multiple Results, short-circuiting on first error:
var results = new[]
{
ParseInt("1"),
ParseInt("2"),
ParseInt("3")
};
Result<IReadOnlyList<int>, string> combined = results.Sequence();
// Ok([1, 2, 3])
// If any fails, returns first error
var withError = new[] { ParseInt("1"), ParseInt("bad"), ParseInt("3") };
var failed = withError.Sequence(); // Error("Invalid number")
Apply a Result-returning function to each element:
var numbers = new[] { "1", "2", "3" };
var result = numbers.Traverse(ParseInt);
// Ok([1, 2, 3])
// Short-circuits on first error
var mixed = new[] { "1", "bad", "3" };
var failed = mixed.Traverse(ParseInt); // Error on "bad"
Collect all errors instead of short-circuiting:
var validations = new[]
{
ValidateEmail(email),
ValidateAge(age),
ValidatePassword(password)
};
var results = validations.Combine();
// Returns all validation errors if any fail
// Success only if all succeed
// SequenceAsync
var asyncResults = new[] { GetUserAsync(1), GetUserAsync(2) };
var combined = await asyncResults.SequenceAsync();
// TraverseAsync
var ids = new[] { 1, 2, 3 };
var users = await ids.TraverseAsync(async id => await GetUserAsync(id));
Transform error types:
var result = operation()
.MapError(ex => new ValidationError(ex.Message));
// Change error type
Result<int, DatabaseError> dbResult = GetFromDatabase();
Result<int, string> stringResult = dbResult.MapError(e => e.Message);
Provide alternative on error:
var result = TryPrimarySource()
.OrElse(error => TrySecondarySource())
.OrElse(error => TryTertiarySource());
// Async variant
var asyncResult = await TryPrimaryAsync()
.OrElseAsync(async err => await TrySecondaryAsync());
Convert errors to success values:
var result = RiskyOperation()
.Recover(error => DefaultValue);
// Conditional recovery
var recovered = operation()
.Recover(error => error switch
{
NotFoundError => DefaultUser,
TimeoutError => CachedUser,
_ => throw new InvalidOperationException()
});
Execute side effects without transforming the Result:
var result = operation()
.Do(value => _logger.LogInformation("Success: {Value}", value))
.DoWhenError(error => _logger.LogError("Failed: {Error}", error));
// Continues the pipeline
result.Do(LogSuccess).DoWhenError(LogError).Map(Transform);
public record User(int Id, string Email, int Age);
public record ValidationError(string Field, string Message);
public Result<User, ValidationError> CreateUser(string email, int age)
{
return ValidateEmail(email)
.Bind(validEmail => ValidateAge(age)
.Map(validAge => new User(0, validEmail, validAge)))
.Then(SaveUser);
}
Result<string, ValidationError> ValidateEmail(string email)
{
return email.Contains("@")
? Result<string, ValidationError>.Success(email)
: Result<string, ValidationError>.Failure(
new ValidationError("Email", "Invalid format"));
}
Result<int, ValidationError> ValidateAge(int age)
{
return age >= 18
? Result<int, ValidationError>.Success(age)
: Result<int, ValidationError>.Failure(
new ValidationError("Age", "Must be 18 or older"));
}
Result<User, ValidationError> SaveUser(User user)
{
// Database operation
return Result<User, ValidationError>.Success(user with { Id = 123 });
}
// Usage
var result = CreateUser("user@example.com", 25);
result.Match(
onSuccess: user => Console.WriteLine($"Created user {user.Id}"),
onError: error => Console.WriteLine($"{error.Field}: {error.Message}")
);
[HttpPost]
public IActionResult CreateUser([FromBody] CreateUserRequest request)
{
var result = ValidateRequest(request)
.Then(CreateUserFromRequest)
.Then(SaveToDatabase);
return result.Match(
onSuccess: user => Ok(new { userId = user.Id }),
onError: error => BadRequest(new { error = error.Message })
);
}
Resultly embraces these principles:
Resultly is designed for zero-allocation scenarios:
[MethodImpl]Traditional exception handling:
try
{
var user = GetUser(id);
var validated = ValidateUser(user);
var saved = SaveUser(validated);
return saved;
}
catch (NotFoundException ex)
{
// Handle not found
}
catch (ValidationException ex)
{
// Handle validation
}
catch (DatabaseException ex)
{
// Handle database error
}
With Resultly:
return GetUser(id)
.Then(ValidateUser)
.Then(SaveUser)
.MapError(error => /* handle all errors uniformly */);
MIT License - see LICENSE for details.
Contributions are welcome! Please feel free to submit a Pull Request.
Run scripts/install-format-hook.ps1 once to wire up a pre-commit hook that executes dotnet msbuild -target:FormatAll. The hook blocks the commit if formatting fails or produces additional changes, making it easier to keep the repo consistent:
pwsh scripts/install-format-hook.ps1
Inspired by:
Note: As of November 2025, semantic match extension methods are distributed in a separate package:
Resultly— core types only (no generator, no semantic match extensions)Resultly.Extensions.SemanticMatch— add this package to get semantic match extensions for all Resultly types (includes the generator)To use semantic match extensions, reference only
Resultly.Extensions.SemanticMatchin your project:dotnet add package Resultly.Extensions.SemanticMatchThis package depends on
Resultlyand includes the source generator. Do not referenceResultly.Generatorsdirectly.This structure avoids ambiguous extension methods and ensures only one set of semantic match extensions is generated per build.