Resultly 0.2.0-alpha.2
See the version list below for details.
dotnet add package Resultly --version 0.2.0-alpha.2
NuGet\Install-Package Resultly -Version 0.2.0-alpha.2
<PackageReference Include="Resultly" Version="0.2.0-alpha.2" />
<PackageVersion Include="Resultly" Version="0.2.0-alpha.2" />
<PackageReference Include="Resultly" />
paket add Resultly --version 0.2.0-alpha.2
#r "nuget: Resultly, 0.2.0-alpha.2"
#:package Resultly@0.2.0-alpha.2
#addin nuget:?package=Resultly&version=0.2.0-alpha.2&prerelease
#tool nuget:?package=Resultly&version=0.2.0-alpha.2&prerelease
Resultly
High-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.
Features
- 🚀 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
- 🏷️ Semantic Match — Source-generated semantic parameter names with sync and async overloads (works for
ResultandTask<Result>across every arity) - 🔍 Type-Based Error Extraction —
TryGetError<T>()for extracting errors by type - 🔀 Pure Discriminated Unions — Either types for type-safe unions without success/error semantics
- Comprehensive — extensive XML documentation and 300+ tests with high code coverage
Installation
dotnet add package Resultly
Supports: .NET 9.0 | .NET 8.0 (LTS) | .NET Standard 2.1
Quick Start
Example Project
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, and collection 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.
Basic Usage
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}"
);
Multiple Specific Error Types
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:
- Error types are visible in the method signature
- IntelliSense shows all possible error types
- Semantic Match parameter names (generated automatically via source generator)
- Type-safe error extraction with
TryGetError<T>() - Compiler enforces handling of all error cases
Either Types (Pure Discriminated Unions)
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)
);
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:
- Use Result when modeling operations that can fail (database queries, validations, IO)
- Use Either when modeling a choice between semantically equal alternatives (config formats, message types, state machines)
Railway Oriented Programming
Chain operations that automatically short-circuit on errors:
var result = GetUserEmail(userId)
.Bind(ValidateEmail)
.Map(email => email.ToLower())
.Bind(SendNotification);
LINQ Query Syntax
var result =
from user in GetUser(id)
from profile in GetProfile(user.ProfileId)
where profile.IsActive
select new UserViewModel(user, profile);
Core Concepts
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}")
);
Core Operations
Construction
// 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)
Map
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));
Bind (FlatMap)
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));
Then
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);
Ensure & EnsureAll
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")
);
Match
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)
);
Type Extraction
// 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();
Async Support
All operations have async variants:
var result = await ValidateAsync(input)
.ThenAsync(async x => await SaveAsync(x))
.MapAsync(async x => await TransformAsync(x));
Complete Operator Reference
Transformation Operators
- Map / MapAsync - Transform success value
- MapError - Transform error value
- Bind / BindAsync - Chain Result-returning operations (flatMap)
- Then / ThenAsync - Alias for Bind with clearer semantic intent
Validation Operators
- Ensure - Single validation with predicate
- EnsureAll - Multiple validations, collect all errors
- Where - LINQ filter support
Error Recovery Operators
- OrElse / OrElseAsync - Provide fallback on error
- Recover - Convert error to success value
- DefaultWith - Provide default value on error
- DefaultIfError - Use default value from function
Side Effect Operators
- Do - Execute action on success without transformation
- DoWhenError - Execute action on error without transformation
- DoAsync / DoWhenErrorAsync - Async variants
Inspection Operators
- Match / MatchAsync - Exhaustive pattern matching with return value
- MatchSemantic - Type-specific semantic matching (generated)
- TryGetError<T> - Extract specific error type
- TryPick<T> - Extract specific type from Either
- Get - Get all values as tuple (Either types)
Query Operators (LINQ)
- Select / SelectMany - LINQ query syntax support
- Where - Filter with predicate
Conversion Operators
- ToEither - Convert Result to Either
- ToOption - Convert to Option/Maybe type (if implemented)
Type Hierarchy
Result Types
// 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 Types
// 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();
Construction Examples
// 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);
Collection Operations
Sequence
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")
Traverse
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"
Combine
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
Async Collection Operations
// 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));
Error Handling & Recovery
MapError
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);
OrElse
Provide alternative on error:
var result = TryPrimarySource()
.OrElse(error => TrySecondarySource())
.OrElse(error => TryTertiarySource());
// Async variant
var asyncResult = await TryPrimaryAsync()
.OrElseAsync(async err => await TrySecondaryAsync());
Recover
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()
});
Do & DoWhenError
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);
Practical Example
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}")
);
ASP.NET Core Integration
[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 })
);
}
Philosophy
Resultly embraces these principles:
- Make invalid states unrepresentable — errors are explicit in the type system
- Fail fast and explicitly — no hidden exceptions, all failures are values
- Compose operations — build complex logic from simple, composable parts
- Railway Oriented Programming — success and failure paths are separate tracks
Performance
Resultly is designed for zero-allocation scenarios:
- Struct-based types where possible
- Aggressive inlining via
[MethodImpl] - No reflection or dynamic dispatch
- Minimal boxing/unboxing
Comparison with Exceptions
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 */);
License
MIT License - see LICENSE for details.
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
Formatting Hook
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
Credits
Inspired by:
- F# Result type
- Rust's Result<T, E>
- Railway Oriented Programming by Scott Wlaschin
- Functional programming patterns in Haskell and Scala
| 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 is compatible. 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 is compatible. 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 | netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.1 is compatible. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | 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.1
- No dependencies.
-
net8.0
- No dependencies.
-
net9.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.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.7.0 | 264 | 11/13/2025 |
| 0.5.0 | 258 | 11/13/2025 |
| 0.4.0 | 260 | 11/13/2025 |
| 0.3.0 | 260 | 11/13/2025 |
| 0.2.4 | 254 | 11/11/2025 |
| 0.2.3 | 184 | 11/9/2025 |
| 0.2.0-alpha.8 | 146 | 11/9/2025 |
| 0.2.0-alpha.7 | 149 | 11/9/2025 |
| 0.2.0-alpha.6 | 144 | 11/9/2025 |
| 0.2.0-alpha.5 | 145 | 11/3/2025 |
| 0.2.0-alpha.4 | 133 | 11/3/2025 |
| 0.2.0-alpha.2 | 134 | 11/2/2025 |
| 0.2.0-alpha.1 | 142 | 11/2/2025 |
| 0.1.0-alpha.3 | 74 | 11/2/2025 |
| 0.1.0-alpha.1 | 75 | 11/2/2025 |