Esox.SharpAndRusty
1.7.4
See the version list below for details.
dotnet add package Esox.SharpAndRusty --version 1.7.4
NuGet\Install-Package Esox.SharpAndRusty -Version 1.7.4
<PackageReference Include="Esox.SharpAndRusty" Version="1.7.4" />
<PackageVersion Include="Esox.SharpAndRusty" Version="1.7.4" />
<PackageReference Include="Esox.SharpAndRusty" />
paket add Esox.SharpAndRusty --version 1.7.4
#r "nuget: Esox.SharpAndRusty, 1.7.4"
#:package Esox.SharpAndRusty@1.7.4
#addin nuget:?package=Esox.SharpAndRusty&version=1.7.4
#tool nuget:?package=Esox.SharpAndRusty&version=1.7.4
Esox.SharpAndRusty
A production-focused C# library that brings Rust-inspired patterns to .NET, including Result<T, E> for type-safe error handling and Option<T> for representing optional values without null references.
⚠️ Disclaimer
This library is provided "as is" without warranty of any kind, either express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and non-infringement. In no event shall the authors or copyright holders be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the software or the use or other dealings in the software.
Use at your own risk. The library is maintained and tested for production use, but it is your responsibility to evaluate its suitability for your specific use case and test it in your environment before deployment.
Features
- ✅ Type-Safe Error Handling: Explicitly represent success and failure states in your type signatures
- ✅ Option Type: Rust-inspired
Option<T>for representing optional values without null references - ✅ Rust-Inspired API: Familiar patterns for developers coming from Rust or functional programming
- ✅ Rich Error Type: Rust-inspired
Errortype with context chaining, metadata, and error categorization - ✅ Zero Overhead:
Result<T,E>is areadonly structfor minimal allocation pressure - ✅ ExtendedResult<T,TE>: Record-based alternative with sealed
Success/Failuresubtypes for exhaustive C# switch expressions and structural equality - ✅ Functional Composition: Chain operations with
Map,Bind,MapError, andOrElse - ✅ Railway-Oriented Programming: Build flat success/failure pipelines with
Bind,Map,Ensure,Tap,MapError, andOrElse(see Railway-Oriented Programming) - ✅ Error Accumulation:
Validation<T,E>withApply,TraverseValidation, andSequencecollects all errors instead of stopping at the first - ✅ Either and Collection Helpers:
Either<L,R>, plusTraverse,Sequence,SequenceAll, andPartitionfor working with collections of results - ✅ Pattern Matching: Use the
Matchmethod for elegant success/failure handling - ✅ Full Equality Support: Implements
IEquatable<T>with proper==,!=, andGetHashCode() - ✅ Implicit Conversions: Concise result and option creation with
Result<int, string> r = 42;andOption<int> o = 42; - ✅ Safe Value Extraction:
ValueOption,ErrorOption,TryGetValue,UnwrapOr,UnwrapOrElse,Expect, andContainsmethods - ✅ Out-Free Parsing:
"123".TryParse<int>()and delegate-basedTryParsehelpers that returnResult<T, Error> - ✅ Exception Handling Helpers: Built-in
TryandTryAsyncfor wrapping exception-prone operations in bothResult<T,E>andExtendedResult<T,TE> - ✅ Inspection Methods: Execute side effects with
Inspect,InspectErr, andTap - ✅ LINQ Query Syntax: Full support for C# LINQ query comprehension with
from,select, and more - ✅ Collection Operations:
CombineandPartitionfor batch processing - ✅ Full Async Support: Async
Result<T, E>composition withMapAsync,BindAsync,EnsureAsync,TapAsync,OrElseAsync, andCombineAsync, preserving any error typeE - ✅ Cancellation Support: Async result operations accept
CancellationTokenand throwOperationCanceledExceptionwhen cancellation is requested - ✅ Multi-targeted: Core and EF Core packages target .NET 8, .NET 9, and .NET 10; the analyzer targets .NET Standard 2.0
- ✨ Roslyn Analyzer: Enforces proper Result/Option handling at compile-time (like Rust's
#[must_use]) - 🧪 Experimental: Mutex<T>: Rust-inspired mutual exclusion primitive with Result-based locking, safe async disposal, and cooperative cancellation (works in both sync and async contexts)
- 🧪 Experimental: RwLock<T>: Rust-inspired reader-writer lock for shared data access (synchronous only — guards must not be held across an
await)
Documentation
- 📖 Feature Documentation - Detailed guides and API reference
- 🚀 Advanced Features - Advanced patterns and techniques
- 🔍 Analyzers - Roslyn analyzer documentation
- 📝 Changelog - Version history and breaking changes
- 🤝 Contributing - How to contribute to the project
Philosophy
Esox.SharpAndRusty is meant to make error handling something you design rather than something that happens to you. It is not meant to replace exceptions or to turn C# into Rust. The guidelines I follow, and built the library around:
- Expected failures are values, bugs are exceptions: Bad input, a missing record or a rejected request belong in a
Result. A null reference from a programming mistake or a corrupted state should still throw. - Fail fast or collect everything, depending on the situation: Use
Resultwhen steps depend on each other. UseValidation<T,E>when they are independent and the caller benefits from seeing every problem at once. - Errors should explain themselves:
Errorcarries a kind, context and metadata, so a failure that reaches your logs says what wrong and where. - Strictness is opt-in: The analyzer warns when a
ResultorOptionis silently ignored. You can raise it to an error when your team is ready or turn it off.Unwrap()andExpect()exist because sometimes they are the right call. - Adopt it gradually: Start with one service boundary or one endpoint. Nothing requires rewriting the codebase and
Try/TryAsynclet you wrap code that throws.
When not to use it: for a trivial two-step operation, plain code is often clearer. Result types add ceremony, so use them where explicit failure handling pays for itself.
Installation
Install the core package from NuGet:
dotnet add package Esox.SharpAndRusty
For Entity Framework Core integrations, install the optional package:
dotnet add package Esox.SharpAndRusty.EntityFrameworkCore
The analyzer is optional and can be added when compile-time warnings for ignored Result and Option values are desired:
dotnet add package Esox.SharpAndRusty.Analyzers
Build and test from source
# Clone the repository
git clone https://github.com/snoekiede/Esox.SharpAndRusty.git
cd Esox.SharpAndRusty
# Build the solution
dotnet build Esox.SharpAndRusty.slnx
# Run tests
dotnet test Esox.SharpAndRusty.slnx
Release validation
The release validation workflow builds all configured target frameworks, runs the tests, scans dependencies for known vulnerabilities, and creates the NuGet packages:
dotnet restore Esox.SharpAndRusty.slnx
dotnet build Esox.SharpAndRusty.slnx --configuration Release
dotnet test Esox.SharpAndRusty.slnx --configuration Release -p:ContinuousIntegrationBuild=true
dotnet list Esox.SharpAndRusty.slnx package --vulnerable --include-transitive
dotnet pack Esox.SharpAndRusty.slnx --configuration Release --no-build
Assembly signing is optional for local builds. Configure a secure key path for signed releases:
dotnet pack Esox.SharpAndRusty.slnx --configuration Release \
-p:SignAssembly=true \
-p:EsoxSigningKeyFile=/secure/path/Esox.SharpAndRusty.snk
Quick Start
using Esox.SharpAndRusty.Types;
using Esox.SharpAndRusty.Extensions;
// Create a successful result (explicit)
var success = Result<int, string>.Ok(42);
// Create a failed result (explicit)
var failure = Result<int, string>.Err("Something went wrong");
// Or use implicit conversions for concise syntax
Result<int, string> quick = 42; // Ok(42)
Result<int, string> quickErr = "Something went wrong"; // Err("Something went wrong")
// Pattern match to handle both cases
var message = success.Match(
success: value => $"Got value: {value}",
failure: error => $"Got error: {error}"
);
// Or use LINQ query syntax!
var result = from x in Result<int, string>.Ok(10)
from y in Result<int, string>.Ok(20)
select x + y;
// Result: Ok(30)
// Use the Error type for rich error handling
var richResult = ErrorExtensions.Try(() => int.Parse("42"))
.Context("Failed to parse user age")
.WithMetadata("input", "42")
.WithKind(ErrorKind.ParseError);
// Use Option<T> for optional values (with implicit conversion)
Option<int> FindUser(int id) => id > 0
? id // Implicit conversion to Some
: new Option<int>.None();
var userOption = FindUser(42);
var optionMessage = userOption switch
{
Option<int>.Some(var id) => $"Found user {id}",
Option<int>.None => "User not found",
_ => "Unknown"
};
// Out-free parsing with rich errors
Result<int, Error> parsedAge = "42".TryParse<int>();
// Value and error extraction without out parameters
Option<int> ageOption = parsedAge.ValueOption();
Option<Error> parseErrorOption = parsedAge.ErrorOption();
🚂 Railway-Oriented Programming
A workflow built from Result types has two tracks: a success track and a failure track. Every step runs only while you are still on the success track. The first failure switches you to the failure track, and the remaining steps are skipped. This approach is known as railway-oriented programming (popularised by Scott Wlaschin for F#), and it replaces nested if/try blocks with a flat pipeline.
| Railway concept | In Esox.SharpAndRusty |
|---|---|
| Success / failure track | Ok / Err on Result<T, E> |
| Switch function (a step that can fail) | Bind, or from ... in ... in a LINQ query |
| Single-track function (a step that cannot fail) | Map |
| Check a condition on the success track | Ensure |
| Adapting code that throws | Try / TryAsync |
| Side effect that leaves the result unchanged | Tap, Inspect, InspectErr |
| Translating the error type | MapError |
| Recovering back onto the success track | OrElse |
| Running steps independently and collecting all errors | Validation<T, E> (see below) |
| End of the line | Match, UnwrapOr |
using Esox.SharpAndRusty.Extensions;
using Esox.SharpAndRusty.Types;
Result<int, string> ParseQuantity(string raw) =>
int.TryParse(raw, out var n) ? n : $"'{raw}' is not a number";
Result<Order, string> PlaceOrder(OrderRequest request) =>
ParseQuantity(request.Quantity) // start on the success track
.Ensure(q => q > 0, q => $"Quantity must be positive, got {q}")
.Ensure(q => q <= 100, "Quantity is limited to 100")
.Map(q => new Order(request.CustomerId, q)) // single-track function
.Tap(
order => Console.WriteLine($"Accepted order for {order.CustomerId}"),
error => Console.WriteLine($"Rejected: {error}")); // side effect, result unchanged
var message = PlaceOrder(new OrderRequest("c-1", "250")).Match(
success: order => $"Order placed: {order.Quantity} item(s)",
failure: error => $"Order rejected: {error}");
// "Order rejected: Quantity is limited to 100" - no exception was thrown anywhere
record OrderRequest(string CustomerId, string Quantity); // types go at the bottom of Program.cs
record Order(string CustomerId, int Quantity);
Code that throws joins the railway through Try, and Bind continues from there:
Result<string, string> ReadConfig(string path) =>
Result<string, string>.Try(
() => File.ReadAllText(path),
ex => $"Cannot read '{path}': {ex.Message}");
Result<int, string> QuantityFromFile(string path) =>
ReadConfig(path).Bind(ParseQuantity);
// Recover back onto the success track, or translate the error type
var withDefault = ParseQuantity("oops").OrElse(_ => Result<int, string>.Ok(1));
var errorLength = ParseQuantity("oops").MapError(text => text.Length);
Collecting all errors with Validation<T, E>
A Result pipeline is fail-fast: later steps usually depend on earlier ones, so it stops at the first error. When the steps are independent (for example the fields of a form) you usually want every error at once. Validation<T, E> is the parallel-track counterpart: it accumulates errors instead of stopping.
Validation<string, string> ValidateEmail(string email) =>
email.Contains('@')
? Validation<string, string>.Valid(email)
: Validation<string, string>.Invalid("Email must contain '@'");
Validation<int, string> ValidateAge(int age) =>
age >= 18
? Validation<int, string>.Valid(age)
: Validation<int, string>.Invalid("Must be at least 18");
var signup = ValidationExtensions.Apply(
ValidateEmail("nope"),
ValidateAge(15),
(email, age) => new Signup(email, age));
// Invalid: ["Email must contain '@'", "Must be at least 18"] - both errors, not just the first
// Back onto a single track when you need a Result again
Result<Signup, string> asResult = signup.ToResult(errors => string.Join("; ", errors));
// Err("Email must contain '@'; Must be at least 18")
record Signup(string Email, int Age);
For collections, TraverseValidation validates every element and collects all errors, while Traverse (for Result) stops at the first one:
var parsed = new[] { "1", "x", "3", "y" }.TraverseValidation(s =>
int.TryParse(s, out var n)
? Validation<int, string>.Valid(n)
: Validation<int, string>.Invalid($"'{s}' is not a number"));
// Invalid: ["'x' is not a number", "'y' is not a number"]
Rule of thumb: use Result when steps depend on each other (fail fast), and Validation when steps are independent and the caller benefits from seeing every problem.
When not to use it: Result types model expected failures (bad input, missing data, a rejected request). Programming errors and unrecoverable conditions are still best expressed as exceptions, and a pipeline of two trivial steps is often clearer as plain code.
🔍 Roslyn Analyzer - Compile-Time Safety (Optional)
An optional Roslyn analyzer is available as a separate package that enforces proper handling of Result<T, E> and Option<T> types at compile-time, similar to Rust's #[must_use] attribute. This prevents accidentally ignoring potential errors or missing values.
Installation
# Install the core library (required)
dotnet add package Esox.SharpAndRusty
# Optionally install the analyzer for compile-time warnings
dotnet add package Esox.SharpAndRusty.Analyzers
Compile-Time Warnings
Once the analyzer is installed, it will warn you when Result or Option values are not properly handled:
// ❌ This triggers warning ESOX1001
public void ProcessData()
{
GetResult(); // warning: Result<int, string> returned by 'GetResult' must be used
}
// ✅ This is properly handled - no warning
public void ProcessData()
{
var result = GetResult();
result.Match(
ok => Console.WriteLine($"Success: {ok}"),
err => Console.WriteLine($"Error: {err}"));
}
Analyzer Warnings
ESOX1001: Result value must be used
- Triggered when: A method returning
Result<T, E>is called but its value is ignored - Severity: Warning (configurable)
- Help: https://github.com/snoekiede/Esox.SharpAndRusty/wiki/ESOX1001
ESOX1002: Option value must be used
- Triggered when: A method returning
Option<T>is called but its value is ignored - Severity: Warning (configurable)
- Help: https://github.com/snoekiede/Esox.SharpAndRusty/wiki/ESOX1002
Configuration
Configure the analyzer in your .editorconfig:
# Promote to errors (recommended for new projects)
dotnet_diagnostic.ESOX1001.severity = error
dotnet_diagnostic.ESOX1002.severity = error
# Or disable (not recommended)
dotnet_diagnostic.ESOX1001.severity = none
Explicit Intent
If you genuinely need to ignore a Result or Option, make it explicit:
// Shows explicit intent - no warning
_ = GetResult();
Learn More:
Usage Examples
Option<T> - Type-Safe Optional Values
The Option<T> type represents an optional value - either Some(value) or None. This provides a type-safe alternative to nullable reference types and eliminates null reference exceptions.
Creating Options
using Esox.SharpAndRusty.Types;
// Create Some with a value
var someOption = new Option<int>.Some(42);
// Or use implicit conversion
Option<int> someOption2 = 42; // Equivalent to new Option<int>.Some(42)
// Create None (no value) — must be explicit
var noneOption = new Option<int>.None();
// Real-world example: Safe dictionary lookup
Option<string> GetConfigValue(Dictionary<string, string> config, string key)
{
return config.TryGetValue(key, out var value)
? new Option<string>.Some(value)
: new Option<string>.None();
}
Pattern Matching with Options
Option<User> FindUser(int userId) => /* ... */;
var user = FindUser(123);
var greeting = user switch
{
Option<User>.Some(var u) => $"Hello, {u.Name}!",
Option<User>.None => "User not found",
_ => "Unknown"
};
// Or use if pattern
if (user is Option<User>.Some(var foundUser))
{
Console.WriteLine($"Processing user: {foundUser.Name}");
}
Using Options in Collections
var users = new List<Option<User>>
{
new Option<User>.Some(new User { Id = 1, Name = "Alice" }),
new Option<User>.None(),
new Option<User>.Some(new User { Id = 2, Name = "Bob" })
};
// Extract all valid users
var validUsers = users
.OfType<Option<User>.Some>()
.Select(opt => opt.Value)
.ToList();
// Result: [User(Alice), User(Bob)]
Record Features
// Options are records, so you get equality by value
var opt1 = new Option<int>.Some(42);
var opt2 = new Option<int>.Some(42);
Console.WriteLine(opt1 == opt2); // True
// Use with expressions to create modified copies
var updated = opt1 with { Value = 43 };
Console.WriteLine(updated); // Some { Value = 43 }
// Use in dictionaries and hash sets
var dict = new Dictionary<Option<string>, int>
{
{ new Option<string>.Some("key"), 1 },
{ new Option<string>.None(), 0 }
};
Comparison with Nullable Types
// Traditional nullable approach - prone to null reference exceptions
string? GetName(int id) => /* might return null */;
var name = GetName(123);
Console.WriteLine(name.Length); // NullReferenceException if null!
// Option approach - compiler forces you to handle None case
Option<string> GetNameSafe(int id) => /* returns Some or None */;
var nameOption = GetNameSafe(123);
var length = nameOption switch
{
Option<string>.Some(var n) => n.Length,
Option<string>.None => 0,
_ => 0
};
// No risk of NullReferenceException!
Result<T, E> - Basic Operations
// Creating results
var success = Result<int, string>.Ok(42);
var failure = Result<int, string>.Err("Not found");
// Checking state
if (success.IsSuccess)
{
Console.WriteLine("Operation succeeded!");
}
if (failure.IsFailure)
{
Console.WriteLine("Operation failed!");
}
// Equality comparison
var result1 = Result<int, string>.Ok(42);
var result2 = Result<int, string>.Ok(42);
Console.WriteLine(result1 == result2); // True
// String representation for debugging
Console.WriteLine(success); // Output: Ok(42)
Console.WriteLine(failure); // Output: Err(Not found)
Success without a value: Ok() (Rust's Ok(()))
For operations that either succeed or fail without producing a value, use Unit as the success type and the parameterless Ok():
Result<Unit, string> Validate(string input) =>
string.IsNullOrEmpty(input)
? Result<Unit, string>.Err("Input cannot be empty")
: Result<Unit, string>.Ok(); // same as Ok(Unit.Value)
// Also available on ExtendedResult
ExtendedResult<Unit, string> done = ExtendedResult<Unit, string>.Ok();
Ok() is only available when the success type is Unit; Result<int, string>.Ok() does not compile.
Safe Value Extraction
var result = GetUserAge();
// Option 1: Try pattern
if (result.TryGetValue(out var age))
{
Console.WriteLine($"User is {age} years old");
}
// Option 2: Provide default value
var age = result.UnwrapOr(0);
// Option 3: Compute default based on error
var age = result.UnwrapOrElse(error =>
{
Logger.Warn($"Failed to get age: {error}");
return 0;
});
// Option 4: Try to get error
if (result.TryGetError(out var error))
{
Console.WriteLine($"Error occurred: {error}");
}
// Option 4b: Out-free access with Option
var maybeAge = result.ValueOption();
var maybeError = result.ErrorOption();
maybeAge.Match(
onSome: age => Console.WriteLine($"User is {age} years old"),
onNone: () => Console.WriteLine("No age available")
);
// Option 5: Expect a value or throw
var age = result.Expect("Age not available");
// Throws InvalidOperationException with message "Age not available" if error
// Option 6: Check if result contains a value
if (result.Contains(42))
{
Console.WriteLine("User is 42 years old");
}
// Keep TryGetValue/TryGetError for .NET interop and tight loops,
// prefer ValueOption/ErrorOption for fluent pipelines.
Out-Free Parsing with Result
Use parsing helpers that return Result<T, Error> instead of bool + out:
using Esox.SharpAndRusty.Extensions;
using Esox.SharpAndRusty.Types;
Result<int, Error> id = "123".TryParse<int>();
Result<Guid, Error> correlationId =
"6f9619ff-8b86-d011-b42d-00cf4fc964ff".TryParse<Guid>();
// Delegate overload for TryParse-style APIs
Result<int, Error> count = "10".TryParse<int>(int.TryParse, nameof(int.TryParse));
var message = id.Match(
success: value => $"Parsed: {value}",
failure: error => $"Parse failed: {error.Message}"
);
Pattern Matching
public string ProcessResult(Result<User, string> result)
{
return result.Match(
success: user => $"Welcome, {user.Name}!",
failure: error => $"Error: {error}"
);
}
// Convert to different type
public int GetLengthOrZero(Result<string, int> result)
{
return result.Match(
success: text => text.Length,
failure: errorCode => 0
);
}
// Void overload — use when you only need side effects, not a return value
public void LogResult(Result<User, string> result)
{
result.Match(
success: user => logger.Info($"Authenticated: {user.Name}"),
failure: error => logger.Warn($"Auth failed: {error}")
);
}
Functional Composition with Map
Transform success values while automatically propagating errors:
Result<int, string> GetUserAge() => Result<int, string>.Ok(25);
// Transform the success value
var result = GetUserAge()
.Map(age => $"User is {age} years old");
// Result: Ok("User is 25 years old")
// Errors propagate automatically
Result<int, string> failed = Result<int, string>.Err("User not found");
var mappedFailed = failed.Map(age => $"User is {age} years old");
// Result: Err("User not found")
Chaining Operations with Bind
Chain multiple operations that can fail, stopping at the first error:
Result<int, string> ParseInt(string input)
{
if (int.TryParse(input, out int value))
return Result<int, string>.Ok(value);
return Result<int, string>.Err($"Cannot parse '{input}' as integer");
}
Result<int, string> Divide(int numerator, int denominator)
{
if (denominator == 0)
return Result<int, string>.Err("Division by zero");
return Result<int, string>.Ok(numerator / denominator);
}
Result<int, string> ValidatePositive(int value)
{
if (value <= 0)
return Result<int, string>.Err("Result must be positive");
return Result<int, string>.Ok(value);
}
// Chain operations - stops at first error
var result = ParseInt("100")
.Bind(value => Divide(value, 5))
.Bind(value => ValidatePositive(value));
// Result: Ok(20)
var failedResult = ParseInt("100")
.Bind(value => Divide(value, 0))
.Bind(value => ValidatePositive(value));
// Result: Err("Division by zero") - ValidatePositive never executes
Async Result Composition
Async result extensions work with any error type E, just like their synchronous counterparts. They preserve the original error type while mapping or binding, and cancellation is reported by throwing OperationCanceledException rather than creating a synthetic result error.
Task<Result<int, string>> LoadCountAsync() =>
Task.FromResult(Result<int, string>.Ok(10));
using var cancellation = new CancellationTokenSource();
var result = await LoadCountAsync()
.MapAsync(count => count * 2, cancellation.Token)
.BindAsync(async count =>
{
await Task.Yield();
return Result<int, string>.Ok(count + 1);
}, cancellation.Token);
// Result: Ok(21)
var checkedResult = await LoadCountAsync()
.EnsureAsync(count => count > 0, count => $"Invalid count: {count}", cancellation.Token);
// Result: Ok(10)
MapAsync is also available on Result<T, E> when the mapper itself is asynchronous. BindAsync skips its binder for a failed result, and EnsureAsync skips its predicate for a failed result. In all cases, the original error is propagated unchanged.
LINQ Query Syntax
Use familiar C# LINQ query syntax for elegant error handling:
// Simple query
var result = from x in ParseInt("10")
from y in ParseInt("20")
select x + y;
// Result: Ok(30)
// Complex query with validation
var result = from input in ParseInt("100")
from divisor in ParseInt("5")
from quotient in Divide(input, divisor)
from validated in ValidatePositive(quotient)
select $"Result: {validated}";
// Result: Ok("Result: 20")
// Error propagation - stops at first error
var result = from x in ParseInt("10")
from y in ParseInt("abc") // Parse fails here
from z in ParseInt("30") // Never executes
select x + y + z;
// Result: Err("Cannot parse 'abc' as integer")
// Works with different types
var result = from name in GetUserName(userId)
from age in GetUserAge(userId)
select $"{name} is {age} years old";
Note on Validation: LINQ where clauses are not supported for Result types because predicates cannot provide meaningful error messages. Instead, use Bind with explicit validation:
// ❌ where is not available (by design)
// var result = from x in GetValue()
// where x > 5 // Cannot provide error message
// select x * 2;
// ✅ Use Bind with explicit validation instead
var result = GetValue()
.Bind(x => x > 5
? Result<int, string>.Ok(x)
: Result<int, string>.Err("Value must be greater than 5"))
.Map(x => x * 2);
// ✅ Or use validation helper functions in LINQ queries
Result<int, string> ValidatePositive(int value) =>
value > 0
? Result<int, string>.Ok(value)
: Result<int, string>.Err("Value must be positive");
var result = from x in GetValue()
from validated in ValidatePositive(x)
select validated * 2;
Combining Map and Bind
var result = ParseInt("42")
.Map(x => x * 2) // Transform value: 42 -> 84
.Bind(x => Divide(x, 2)) // Chain operation: 84 / 2 = 42
.Map(x => $"Result: {x}"); // Transform to string
// Result: Ok("Result: 42")
Fallback with OrElse
Provide alternative results when operations fail:
Result<User, string> GetUserFromCache(int id) =>
Result<User, string>.Err("Not in cache");
Result<User, string> GetUserFromDatabase(int id) =>
Result<User, string>.Ok(new User { Id = id, Name = "John" });
// Try cache first, fallback to database
var user = GetUserFromCache(123)
.OrElse(error =>
{
Logger.Info($"Cache miss: {error}. Trying database...");
return GetUserFromDatabase(123);
});
// Result: Ok(User { Id = 123, Name = "John" })
Side Effects with Inspect
Execute side effects without transforming the result:
var result = GetUser(userId)
.Inspect(user => Logger.Info($"Found user: {user.Name}"))
.InspectErr(error => Logger.Error($"User lookup failed: {error}"))
.Map(user => user.Email);
// Logs are written, but result is transformed only on success
Exception Handling
Wrap operations that might throw exceptions:
// Synchronous operation
var result = Result<int, string>.Try(
operation: () => int.Parse("42"),
errorHandler: ex => $"Parse failed: {ex.Message}"
);
// Result: Ok(42)
// Async operation
var asyncResult = await Result<User, string>.TryAsync(
operation: async () => await httpClient.GetUserAsync(userId),
errorHandler: ex => $"HTTP request failed: {ex.Message}"
);
// Real-world example: File operations
var fileContent = Result<string, string>.Try(
operation: () => File.ReadAllText("config.json"),
errorHandler: ex => $"Failed to read config: {ex.Message}"
);
// Using the Error type for automatic exception conversion
var richResult = ErrorExtensions.Try(() => int.Parse("42"));
// Automatically converts exceptions to Error with appropriate ErrorKind
var asyncRichResult = await ErrorExtensions.TryAsync(
async () => await File.ReadAllTextAsync("config.json"));
// Returns Result<string, Error>
Rich Error Handling with Error Type
The library includes a Rust-inspired Error type for rich error handling with production-grade optimizations:
using Esox.SharpAndRusty.Types;
using Esox.SharpAndRusty.Extensions;
// Automatic exception conversion with error kinds
var result = ErrorExtensions.Try(() => File.ReadAllText("config.json"))
.Context("Failed to load configuration")
.WithMetadata("path", "config.json")
.WithKind(ErrorKind.NotFound);
// Type-safe metadata with compile-time safety
var error = Error.New("Operation failed")
.WithMetadata("userId", 123) // Type-safe: int
.WithMetadata("timestamp", DateTime.UtcNow) // Type-safe: DateTime
.WithMetadata("isRetryable", true); // Type-safe: bool
// Type-safe metadata retrieval
if (error.TryGetMetadata("userId", out int userId))
{
Console.WriteLine($"Failed for user: {userId}");
}
// Context chaining for error propagation
Result<Config, Error> LoadConfig(string path)
{
return ReadFile(path)
.Context($"Failed to load config from {path}")
.Bind(content => ParseConfig(content)
.Context("Failed to parse configuration")
.WithKind(ErrorKind.ParseError));
}
// Full error chain display with metadata
if (result.TryGetError(out var error))
{
Console.Error.WriteLine(error.GetFullMessage());
// Output:
// "NotFound: Failed to load configuration"
// [path=/etc/app/config.json]
// [attemptCount=3]
// Caused by: "Io: File not found: config.json"
}
// Error categorization with ErrorKind
if (error.Kind == ErrorKind.NotFound)
{
// Handle not found specifically
}
// Optional stack trace capture (performance-aware)
var errorWithTrace = error.CaptureStackTrace(includeFileInfo: false); // Fast
var detailedError = error.CaptureStackTrace(includeFileInfo: true); // Detailed
Cancellation behavior for async error helpers
ContextAsync, WithContextAsync, and both WithMetadataAsync overloads throw OperationCanceledException when their CancellationToken is cancelled. Previously, these methods returned a failed Result containing an Error with ErrorKind.Interrupted; this is a breaking behavior change for callers that handled cancellation by inspecting the returned result. Callers should handle cancellation with try/catch or allow it to propagate:
try
{
var result = await LoadUserAsync(userId)
.ContextAsync("Failed to load user", cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Handle or propagate cancellation.
}
These helpers await the input result task before checking the token, so the token does not cancel an input task that is still running; cancellation is observed after that task completes.
Production Features:
- ✅ ImmutableDictionary - Efficient metadata storage with structural sharing
- ✅ Type-safe metadata API - Generic overloads for compile-time type safety
- ✅ Metadata type validation - Validates at addition time, not serialization
- ✅ Depth Limiting - Error chains truncated at 50 levels (prevents stack overflow)
- ✅ Circular Reference Detection - HashSet-based cycle detection
- ✅ Expanded Exception Mapping - 11 common exception types automatically mapped
- ✅ Configurable Stack Traces - Optional file info for performance tuning
- ✅ Metadata Type Validation - Validates types at addition time, not serialization
Error Kind Categories:
NotFound- Entity not foundInvalidInput- Invalid dataPermissionDenied- Insufficient privilegesTimeout- Operation timed outInterrupted- Operation cancelled/interruptedParseError- Parsing failedIo- I/O errorResourceExhausted- Out of memory, disk full, etc.InvalidOperation- Operation invalid for current state- And more... (14 categories total)
Exception to ErrorKind Mapping:
FileNotFoundException,DirectoryNotFoundException→NotFoundTaskCanceledException,OperationCanceledException→InterruptedFormatException→ParseErrorOutOfMemoryException→ResourceExhaustedTimeoutException→TimeoutUnauthorizedAccessException→PermissionDenied- And more...
Either<L, R> and Unit
Either<L, R>
Either<L, R> is a neutral two-case union (Left or Right) for when neither side means "error". It is a record with sealed Left and Right subtypes, so it works with C# switch expressions and has structural equality.
Either<string, int> left = new Either<string, int>.Left("not a number");
Either<string, int> right = new Either<string, int>.Right(42);
string text = right.Match(l => $"Left: {l}", r => $"Right: {r}"); // "Right: 42"
var doubled = right.MapRight(x => x * 2); // Right(84)
var swapped = right.Swap(); // Left(42) as Either<int, string>
if (right.TryGetRight(out var value)) { /* value == 42 */ }
// Collections
var items = new[] { left, right };
var (lefts, rights) = items.Partition();
IEnumerable<int> onlyRights = items.Rights();
API: IsLeft, IsRight, TryGetLeft, TryGetRight, Match (value and Action overloads), MapLeft, MapRight, Map, Swap, LeftOption, RightOption; extensions BindLeft, BindRight, IfLeft, IfRight, GetLeftOrDefault, GetRightOrDefault, Partition, Lefts, Rights.
Unit
Unit is the equivalent of Rust's (): use it as the success type when an operation has no meaningful result. All Unit values are equal, Unit.Value is the singleton, and ToString() returns "()". Use the parameterless Ok() to create a successful Result<Unit, E> or ExtendedResult<Unit, TE> (see Success without a value).
ExtendedResult<T, TE>
ExtendedResult<T, TE> is the record-based sibling of Result<T, E>. Its Success and Failure subtypes are sealed records, which enables
- exhaustive C# switch expressions without a catch-all
_ =>arm, and - structural equality out-of-the-box via the record mechanism.
When to choose ExtendedResult vs Result
Result<T, E> |
ExtendedResult<T, TE> |
|
|---|---|---|
| Memory | Stack (readonly struct, zero heap alloc) |
Heap (record class) |
| Switch exhaustiveness | Requires IsSuccess/IsFailure guards |
Native sealed subtypes — compiler enforces exhaustiveness |
| Equality | Manual IEquatable<T> |
Record structural equality |
| LINQ / Map / Bind | Full extension-method support | Full extension-method support |
| Implicit conversions | ✅ | ✅ |
| Best for | Hot paths, high-throughput APIs | Domain models, large payloads, DDD aggregates |
Creating an ExtendedResult
using Esox.SharpAndRusty.Types;
// Explicit factory methods
var ok = ExtendedResult<int, string>.Ok(42);
var err = ExtendedResult<int, string>.Err("Something went wrong");
// Implicit conversions — same ergonomics as Result<T, E>
ExtendedResult<int, string> quick = 42; // Ok(42)
ExtendedResult<int, string> quickErr = "Something went wrong"; // Err(...)
// Tip: when T and TE are the same type, use explicit Ok()/Err() to avoid CS0457
var explicit1 = ExtendedResult<string, string>.Ok("hello");
var explicit2 = ExtendedResult<string, string>.Err("oops");
Pattern matching
Because Success and Failure are sealed records, the compiler can verify exhaustiveness at compile time:
var result = ExtendedResult<int, string>.Ok(42);
// Exhaustive switch expression — no _ => required
var message = result switch
{
ExtendedResult<int, string>.Success(var value) => $"Value: {value}",
ExtendedResult<int, string>.Failure(var error) => $"Error: {error}"
};
// Match helper — functional style
var display = result.Match(
success: value => $"Got: {value}",
failure: error => $"Failed: {error}"
);
// Single-branch if-pattern
if (result is ExtendedResult<int, string>.Success(var n))
Console.WriteLine($"Processing {n}");
Functional composition
// Map — transform the success value
ExtendedResult<int, string> lengthResult =
ExtendedResult<string, string>.Ok("hello")
.Map(s => s.Length);
// Success(5)
// Bind — chain operations that can themselves fail
ExtendedResult<int, string> parsed =
ExtendedResult<string, string>.Ok("42")
.Bind(s => int.TryParse(s, out var n)
? ExtendedResult<int, string>.Ok(n)
: ExtendedResult<int, string>.Err($"Cannot parse '{s}'"));
// Success(42)
// OrElse — recover from failure with an alternative result
var recovered = ExtendedResult<int, string>.Err("cache miss")
.OrElse(_ => ExtendedResult<int, string>.Ok(0));
// Success(0)
// Chain multiple fallbacks
var chained = ExtendedResult<int, string>.Err("first")
.OrElse(_ => ExtendedResult<int, string>.Err("second"))
.OrElse(_ => ExtendedResult<int, string>.Ok(99));
// Success(99)
Value extraction
var result = ExtendedResult<int, string>.Ok(42);
// Safe extraction
if (result.TryGetValue(out var value)) Console.WriteLine($"Value: {value}"); // 42
if (result.TryGetError(out var error)) Console.WriteLine($"Error: {error}"); // (never reached)
// Fallback when failure
int v1 = result.UnwrapOr(-1); // 42
int v2 = result.UnwrapOrElse(err => err.Length); // 42 (factory ignored)
var failed = ExtendedResult<int, string>.Err("oops");
int v3 = failed.UnwrapOr(-1); // -1
int v4 = failed.UnwrapOrElse(err => err.Length); // 4
Side effects with Inspect
var result = ExtendedResult<User, string>.Ok(new User { Id = 1 })
.Inspect(user => logger.Info($"Found: {user.Id}"))
.InspectErr(err => logger.Warn($"Lookup failed: {err}"));
// Returns the original result unchanged
Void Match — side-effect-only pattern dispatch
Both Result<T,E> and ExtendedResult<T,TE> provide a void Match(Action<T>, Action<E>) overload for when you need to branch on success/failure purely for side effects (logging, metrics, UI updates) without producing a return value:
// Result<T,E> — void overload
Result<int, string> result = ParseInput(raw);
result.Match(
value => metrics.RecordSuccess(value),
error => metrics.RecordFailure(error)
);
// ExtendedResult<T,TE> — void overload (same ergonomics)
ExtendedResult<Order, string> orderResult = PlaceOrder(cart);
orderResult.Match(
order => { Console.WriteLine($"Order {order.Id} confirmed."); SendConfirmationEmail(order); },
error => { Console.WriteLine($"Order failed: {error}"); NotifySupport(error); }
);
// Both overloads throw ArgumentNullException when either action is null
Exception wrapping with Try / TryAsync
// Synchronous
var result = ExtendedResult<int, string>.Try(
operation: () => int.Parse("42"),
errorHandler: ex => $"Parse failed: {ex.Message}"
);
// Success(42)
// Asynchronous
var asyncResult = await ExtendedResult<string, string>.TryAsync(
operation: async () => await File.ReadAllTextAsync("config.json"),
errorHandler: ex => $"IO error: {ex.Message}"
);
// Error result when parsing fails
var bad = ExtendedResult<int, string>.Try(
operation: () => int.Parse("not-a-number"),
errorHandler: ex => $"Parse failed: {ex.Message}"
);
// Failure("Parse failed: Input string was not in a correct format.")
Equality — record semantics
var a = ExtendedResult<int, string>.Ok(42);
var b = ExtendedResult<int, string>.Ok(42);
Console.WriteLine(a == b); // True — value equality via record
var c = ExtendedResult<int, string>.Err("error");
var d = ExtendedResult<int, string>.Err("error");
Console.WriteLine(c == d); // True
Console.WriteLine(a == c); // False — Success ≠ Failure
API Reference
Option<T> Type
A type-safe way to represent optional values, eliminating null reference exceptions.
Creating Options
new Option<T>.Some(T value)- Creates an option containing a valuenew Option<T>.None()- Creates an empty optionOption<T> opt = value- Implicit conversion createsSome(value)
Pattern Matching
var result = option switch
{
Option<T>.Some(var value) => /* handle value */,
Option<T>.None => /* handle absence */,
_ => /* fallback */
};
Type Checks
option is Option<T>.Some- Check if option contains a valueoption is Option<T>.None- Check if option is emptyoption is Option<T>.Some(var value)- Extract value with pattern matching
Record Features
- Equality: Options support value-based equality
- Hash Code: Safe to use in collections (HashSet, Dictionary)
- With Expressions: Create modified copies with
with { Value = newValue } - ToString: Automatically formatted as
"Some { Value = ... }"or"None { }"
Example Usage
// Type-safe dictionary lookup
Option<string> GetConfig(string key)
{
return config.TryGetValue(key, out var value)
? new Option<string>.Some(value)
: new Option<string>.None();
}
// Extract values from collections
var validValues = options
.OfType<Option<T>.Some>()
.Select(opt => opt.Value)
.ToList();
Result<T, E> Type
Properties
bool IsSuccess- Returnstrueif the result represents successbool IsFailure- Returnstrueif the result represents failure
Static Factory Methods
Result<T, E> Ok(T value)- Creates a successful resultResult<Unit, E> Ok()- Creates a successful result with no value (like Rust'sOk(())); only available whenTisUnitResult<T, E> Err(E error)- Creates a failed resultResult<T, E> Try(Func<T> operation, Func<Exception, E> errorHandler)- Execute operation with exception handlingTask<Result<T, E>> TryAsync(Func<Task<T>> operation, Func<Exception, E> errorHandler)- Async version of Try
Implicit Conversions
implicit operator Result<T, E>(T value)- Implicitly creates a successful result from a valueimplicit operator Result<T, E>(E error)- Implicitly creates a failed result from an error- Note: When
TandEare the same type, the compiler reports an ambiguous conversion. Use explicitOk()/Err()in that case.
Instance Methods
R Match<R>(Func<T, R> success, Func<E, R> failure)- Pattern match on the result, returning a valuevoid Match(Action<T> success, Action<E> failure)- Pattern match for side effects only; throwsArgumentNullExceptionfor null actionsOption<T> ValueOption()- Get success value asOption<T>(out-free)Option<E> ErrorOption()- Get error value asOption<E>(out-free)bool TryGetValue(out T value)- Try to get the success valuebool TryGetError(out E error)- Try to get the error valueT UnwrapOr(T defaultValue)- Get value or return defaultT UnwrapOrElse(Func<E, T> defaultFactory)- Get value or compute defaultResult<T, E> OrElse(Func<E, Result<T, E>> alternative)- Provide alternative on failureResult<T, E> Inspect(Action<T> action)- Execute action on success valueResult<T, E> InspectErr(Action<E> action)- Execute action on error value
Equality Methods
bool Equals(Result<T, E> other)- Check equalityint GetHashCode()- Get hash codebool operator ==(Result<T, E> left, Result<T, E> right)- Equality operatorbool operator !=(Result<T, E> left, Result<T, E> right)- Inequality operatorstring ToString()- Returns"Ok(value)"or"Err(error)"
ExtendedResult<T, TE> Type
Record-based result type. Success and Failure are sealed records, enabling exhaustive pattern matching and structural equality.
Properties
bool IsSuccess-trueif the result is aSuccessbool IsFailure-trueif the result is aFailure
Static Factory Methods
ExtendedResult<T, TE> Ok(T value)- Creates aSuccessresultExtendedResult<Unit, TE> Ok()- Creates aSuccesswith no value (like Rust'sOk(())); only available whenTisUnitExtendedResult<T, TE> Err(TE error)- Creates aFailureresultExtendedResult<T, TE> Try(Func<T> operation, Func<Exception, TE> errorHandler)- Wraps a synchronous operation; throwsArgumentNullExceptionwhen either argument is nullTask<ExtendedResult<T, TE>> TryAsync(Func<Task<T>> operation, Func<Exception, TE> errorHandler)- Async version ofTry
Implicit Conversions
implicit operator ExtendedResult<T, TE>(T value)- Converts a value toSuccessimplicit operator ExtendedResult<T, TE>(TE error)- Converts an error toFailure- Note: When
TandTEare the same type, use explicitOk()/Err()to avoid a CS0457 ambiguous conversion error.
Instance Methods
TR Match<TR>(Func<T, TR> success, Func<TE, TR> failure)- Functional pattern match, returning a value; throwsArgumentNullExceptionfor null delegatesvoid Match(Action<T> success, Action<TE> failure)- Pattern match for side effects only; throwsArgumentNullExceptionfor null actionsbool TryGetValue(out T value)- Try to get the success valuebool TryGetError(out TE error)- Try to get the error valueT UnwrapOr(T defaultValue)- Get value or return defaultT UnwrapOrElse(Func<TE, T> defaultFactory)- Get value or compute defaultExtendedResult<T, TE> OrElse(Func<TE, ExtendedResult<T, TE>> alternative)- Provide alternative result on failure; throwsArgumentNullExceptionfor null alternativeExtendedResult<T, TE> Inspect(Action<T> action)- Execute side effect on success value; throwsArgumentNullExceptionfor null actionExtendedResult<T, TE> InspectErr(Action<TE> action)- Execute side effect on error value; throwsArgumentNullExceptionfor null action
Equality — Record Semantics
Successvalues are equal when theirValuefields are equal (viaEqualityComparer<T>.Default)Failurevalues are equal when theirErrorfields are equal (viaEqualityComparer<TE>.Default)SuccessandFailureare never equal to each otherint GetHashCode()- Hash combines a discriminant (1 for Success, 2 for Failure) with the inner valuestring ToString()- Returns"Ok(value)"or"Err(error)"
Extension Methods (ParseExtensions)
Out-free wrappers for TryParse-style APIs:
Result<T, Error> TryParse<T>(this string? input, IFormatProvider? provider = null)
where T : IParsable<T>
Result<T, Error> TryParse<T>(this string? input, ParseExtensions.TryParseDelegate<T> tryParse, string? parserName = null)
Example:
Result<int, Error> amount = "42".TryParse<int>();
Result<int, Error> amount2 = "42".TryParse<int>(int.TryParse, nameof(int.TryParse));
Extension Methods (ResultExtensions)
Map<T, E, U>
Transforms the success value while propagating errors:
Result<U, E> Map<T, E, U>(this Result<T, E> result, Func<T, U> mapper)
Example:
var result = Result<int, string>.Ok(5);
var mapped = result.Map<int, string, string>(x => $"Value: {x}");
// Result: Ok("Value: 5")
Bind<T, E, U>
Chains operations that return results (also known as flatMap or andThen):
Result<U, E> Bind<T, E, U>(this Result<T, E> result, Func<T, Result<U, E>> binder)
Example:
var result = Result<int, string>.Ok(10)
.Bind(x => x > 0
? Result<int, string>.Ok(x * 2)
: Result<int, string>.Err("Must be positive"));
// Result: Ok(20)
Ensure
Checks a condition on the success value and switches to the failure track when it does not hold. A result that is already a failure is returned unchanged and the predicate is not called:
Result<T, E> Ensure<T, E>(this Result<T, E> result, Func<T, bool> predicate, Func<T, E> errorFactory)
Result<T, E> Ensure<T, E>(this Result<T, E> result, Func<T, bool> predicate, E error)
Task<Result<T, E>> EnsureAsync<T, E>(this Task<Result<T, E>> resultTask, Func<T, bool> predicate, Func<T, E> errorFactory, CancellationToken cancellationToken = default)
Task<Result<T, E>> EnsureAsync<T, E>(this Task<Result<T, E>> resultTask, Func<T, Task<bool>> predicate, Func<T, E> errorFactory, CancellationToken cancellationToken = default)
ExtendedResult<T, E> has the same synchronous overloads.
Example:
var result = Result<int, string>.Ok(150)
.Ensure(x => x > 0, "Must be positive")
.Ensure(x => x < 100, x => $"{x} must be below 100");
// Result: Err("150 must be below 100")
Select<U> (LINQ Support)
Projects the success value (enables select in LINQ queries):
Result<U, E> Select<U>(this Result<T, E> result, Func<T, U> selector)
Example:
var result = from x in Result<int, string>.Ok(10)
select x * 2;
// Result: Ok(20)
SelectMany<U> (LINQ Support)
Chains results (enables from in LINQ queries):
Result<U, E> SelectMany<U>(this Result<T, E> result, Func<T, Result<U, E>> selector)
Example:
var result = from x in ParseInt("10")
from y in ParseInt("20")
select x + y;
// Result: Ok(30)
Unwrap<T, E>
Extracts the success value or throws an exception (use with caution):
T Unwrap<T, E>(this Result<T, E> result)
Example:
var result = Result<int, string>.Ok(42);
var value = result.Unwrap(); // Returns 42
var failed = Result<int, string>.Err("Error");
var willThrow = failed.Unwrap(); // Throws InvalidOperationException
Error Type
A rich error type inspired by Rust's error handling patterns with production-grade optimizations.
Static Factory Methods
Error New(string message)- Creates a new errorError New(string message, ErrorKind kind)- Creates an error with a specific kindError FromException(Exception exception)- Converts an exception to an error (maps 11+ exception types)
Instance Methods
Error WithContext(string contextMessage)- Adds context to the errorError WithMetadata(string key, object value)- Attaches metadata (validates types)Error WithMetadata<T>(string key, T value) where T : struct- Type-safe metadata attachmentError WithKind(ErrorKind kind)- Changes the error kindError CaptureStackTrace(bool includeFileInfo = false)- Captures the current stack trace (configurable)bool TryGetMetadata(string key, out object? value)- Gets metadatabool TryGetMetadata<T>(string key, out T? value)- Type-safe metadata retrievalstring GetFullMessage()- Gets the full error chain as a string (depth-limited, circular-safe)
Properties
string Message- The error messageErrorKind Kind- The error category (14 predefined kinds)Error? Source- The source error (if chained)string? StackTrace- The captured stack tracebool HasSource- Whether this error has a source
Production Features
- ImmutableDictionary for metadata - O(log n) operations with structural sharing
- Type-safe metadata API - Generic overloads for compile-time type safety
- Metadata type validation - Validates at addition time (primitives, DateTime, Guid, enums, value types)
- Depth limiting - Error chains truncated at 50 levels to prevent stack overflow
- Circular reference detection - HashSet-based cycle detection prevents infinite loops
- Expanded exception mapping - 11 common exception types automatically categorized
- Configurable stack traces - Optional file info for performance tuning
- Equality support - Proper
Equals,GetHashCode,==,!=operators
Performance Characteristics:
- Metadata addition: O(log n) with structural sharing
- Depth limit: Bounded at 50 levels
- Circular detection: O(1) per node
- Memory: Immutable with structural sharing
Why Use Result Types?
Traditional Exception-Based Approach
public User GetUser(int id)
{
var user = database.FindUser(id);
if (user == null)
throw new NotFoundException($"User {id} not found");
return user;
}
// Caller has no indication this method can throw
User user = GetUser(123); // Might throw at runtime!
Result-Based Approach
public Result<User, string> GetUser(int id)
{
var user = database.FindUser(id);
if (user == null)
return Result<User, string>.Err($"User {id} not found");
return Result<User, string>.Ok(user);
}
// Failure is explicit in the type signature
Result<User, string> result = GetUser(123);
var message = result.Match(
success: user => $"Found: {user.Name}",
failure: error => $"Error: {error}"
);
Benefits
- ✅ Explicit Error Handling: Method signatures clearly communicate potential failures
- ✅ Type-Safe Optional Values:
Option<T>eliminates null reference exceptions - ✅ Type Safety: Compile-time guarantees about error handling
- ✅ Rich Error Context: Chain error context as failures propagate up the call stack
- ✅ Error Categorization: 14 predefined error kinds for appropriate handling
- ✅ Performance: Avoid exception overhead for expected failure cases
- ✅ Composability: Easily chain operations with functional combinators
- ✅ Testability: Easier to test both success and failure paths
- ✅ No Null References: Use
Option<T>andResult<T, E>to avoidNullReferenceException - ✅ Better Code Flow: Failures don't break the natural flow of your code
- ✅ Pattern Matching: Leverage C# pattern matching for elegant value handling
- ✅ LINQ Integration: Use familiar C# query syntax for error handling workflows
- ✅ Async/Await Support: Full integration with async patterns including cancellation
- ✅ Cancellable Operations: Graceful cancellation of long-running async operations
- ✅ Debugging Support: Metadata attachment and full error chain display for debugging
Testing
Fast Local vs Full CI Test Runs
For fast local feedback, tests run as net8.0 by default and skip tests tagged with Category=Slow or Category=Stress.
Use these commands:
# Fast local default (single TFM + excludes Slow/Stress)
dotnet test
# Full CI-equivalent matrix (net8.0 + net9.0 + net10.0, includes Slow/Stress)
dotnet test -p:ContinuousIntegrationBuild=true
The library includes comprehensive test coverage with 460+ unit tests covering:
- Result<T, E> (269 tests)
- Basic creation and inspection
- Pattern matching (returning and void overloads)
- Equality and hash code
- Map and Bind operations
- LINQ query syntax integration (SelectMany, Select, from/select)
- Advanced features (MapError, Expect, Tap, Contains)
- Collection operations (Combine, Partition)
- Full async support (MapAsync, BindAsync, TapAsync, OrElseAsync, CombineAsync)
- Cancellation token support (all async methods with cancellation scenarios)
- ExtendedResult<T, TE> (93 tests)
- Creation via
Ok,Err, and implicit conversions - Exhaustive pattern matching via
Success/Failuresubtypes (returning and void overloads) - Equality and hash code (record structural equality)
TryGetValue/TryGetError/UnwrapOr/UnwrapOrElseOrElse/Inspect/InspectErrTry/TryAsyncincluding exception-capture and null-argument handling- Void
Match(Action, Action)— side-effect branching with null-safety
- Creation via
- Option<T> (50 tests)
- Creation and value access
- Pattern matching with switch expressions
- Equality and hash code
- Record functionality (with expressions, ToString)
- Collection integration (List, HashSet, Dictionary, LINQ)
- Edge cases (nested options, tuples, null handling)
- Implicit conversion (value → Some, return statements, method parameters, pattern matching)
- Error type (64 comprehensive tests)
- Context chaining and error propagation
- Type-safe metadata with generics
- Metadata type validation
- Exception conversion with 11 exception types
- Error kind modification
- Stack trace capture (configurable)
- Depth limiting (50 levels)
- Circular reference detection
- Full error chain formatting
- Equality and hash code
- 🧪 Experimental Mutex<T> (48 tests)
- Lock acquisition and release
- Try-lock and timeout variants
- Async locking with cancellation and cooperative cancellation on disposal
- Concurrency stress tests
- RAII guard management
- 🧪 Experimental RwLock<T> (42 tests)
- Read/write guard acquisition and release
- Try-lock and timeout variants
- Live-guard drain-wait on disposal
- Cross-thread
IntoInner()(guard-held and guard-released) - Concurrent read/write-vs-dispose stress test
- Exception handling (Try/TryAsync)
- Side effects (Inspect/InspectErr)
- Value extraction methods
- Null handling for nullable types
Experimental Features
🧪 Mutex<T> & RwLock<T> - Thread-Safe Synchronization Primitives
Status: Experimental - API may change in future versions
Rust-inspired synchronization primitives for protecting shared data. Mutex<T> supports both synchronous and asynchronous contexts. RwLock<T> is synchronous only.
Mutex<T> - Mutual Exclusion
using Esox.SharpAndRusty.Sync;
using Esox.SharpAndRusty.Types;
// Create a mutex protecting shared data
var mutex = new Mutex<int>(0);
// Synchronous locking
var result = mutex.Lock();
if (result.TryGetValue(out var guard))
{
using (guard)
{
guard.Value++; // Safe mutation
} // Lock automatically released
}
// Non-blocking try
var tryResult = mutex.TryLock();
// Async locking with cancellation
var asyncResult = await mutex.LockAsync(cancellationToken);
RwLock<T> - Reader-Writer Lock
using Esox.SharpAndRusty.Sync;
using Esox.SharpAndRusty.Types;
// Create a reader-writer lock
var rwlock = new RwLock<int>(42);
// Multiple readers can access simultaneously
var readResult = rwlock.Read();
if (readResult.TryGetValue(out var readGuard))
{
using (readGuard)
{
Console.WriteLine(readGuard.Value); // Read-only access
}
}
// Exclusive writer access
var writeResult = rwlock.Write();
if (writeResult.TryGetValue(out var writeGuard))
{
using (writeGuard)
{
writeGuard.Value = 100; // Exclusive write access
}
}
Key Features:
- ✅ Result-Based Locking - All lock operations return
Result<Guard<T>, Error> - ✅ RAII Lock Management - Automatic lock release via
IDisposable - ✅ Multiple Lock Strategies - Blocking, try-lock, and timeout variants
- ✅ Type-Safe - Compile-time guarantees for protected data access
- ✅ Mutex<T> Async Support -
Mutex<T>works in both synchronous and asynchronous contexts - ✅ Reader-Writer Optimization -
RwLock<T>allows concurrent readers - ⚠️ RwLock<T> is synchronous only — it has no async API. Guards must never be held across an
await; doing so will block the thread pool and can cause deadlocks.
Mutex<T> Methods:
Lock()- Blocking lock acquisition (sync)TryLock()- Non-blocking attemptTryLockTimeout(TimeSpan)- Lock with timeoutLockAsync(CancellationToken)- Async lockLockAsyncTimeout(TimeSpan, CancellationToken)- Async lock with timeout
RwLock<T> Methods:
Read()- Acquire read lock (allows concurrent readers)TryRead()- Non-blocking read attemptTryReadTimeout(TimeSpan)- Read with timeoutWrite()- Acquire exclusive write lockTryWrite()- Non-blocking write attemptTryWriteTimeout(TimeSpan)- Write with timeoutIntoInner()- Consume the lock and extract the protected value; safe to call from any thread once all guards are released; returnsResult<T, Error>rather than throwing
⚠️ Experimental Notice:
The Mutex<T> and RwLock<T> APIs are currently experimental and may undergo changes based on user feedback and real-world usage patterns. While fully tested, we recommend:
- Using them in non-critical paths initially
- Providing feedback on the API design
- Testing thoroughly in your specific use cases
- Being prepared for potential API changes in minor version updates
✅ Resolved — Mutex<T> Disposal:
Mutex<T>.Dispose() safely handles pending LockAsync() and LockAsyncTimeout() calls: waiting tasks receive a Result.Err rather than hanging, via a disposal-linked CancellationToken and waiter-count tracking. This behaviour is covered by the LockAsync_DisposedDuringWait_ReturnsError and LockAsyncTimeout_DisposedDuringWait_ReturnsError regression tests.
⚠️ Known Limitation - RwLock<T> Disposal while a thread is blocked on entry:
RwLock<T> uses ReaderWriterLockSlim internally. Microsoft's documentation explicitly states that disposing a ReaderWriterLockSlim while other threads are engaged with it is unsupported. If a thread is blocked inside Read() or Write() (waiting for the lock to become available) at the exact moment Dispose() is called on another thread, the outcome is not guaranteed — it is likely to be an ObjectDisposedException on the waiting thread, but cannot be relied upon.
RwLock<T>.Dispose() does wait (up to 5 seconds) for all live guards — locks that have been successfully acquired and not yet released — to drain before disposing the inner lock. This closes the common race of "dispose while another thread still holds a live guard". It is only the narrower "blocked trying to acquire at the exact moment of disposal" case that is subject to the above caveat.
IntoInner() attempts to acquire an exclusive write lock (waiting up to 5 seconds) before extracting the value. All failure paths — ObjectDisposedException if another thread disposed the lock concurrently, LockRecursionException if the caller already holds a read lock on the same thread, and a timeout if guards remain held — are returned as Result<T, Error> rather than thrown. The lock is not disposed on any failure path.
Recommendation: Ensure all work that may acquire the lock has finished before calling Dispose() — for example at application shutdown or after draining a work queue.
For complete synchronization primitives documentation, see the Advanced Features Guide.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | 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 is compatible. 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. |
-
net10.0
- No dependencies.
-
net8.0
- No dependencies.
-
net9.0
- No dependencies.
NuGet packages (3)
Showing the top 3 NuGet packages that depend on Esox.SharpAndRusty:
| Package | Downloads |
|---|---|
|
Esox.SharpAndRusty.AspNetCore
Sharp and Rusty AspNet Core extensions |
|
|
Esox.SharpAndRusty.ObjectPool
A thread-safe, Result-based generic object pool for .NET 8/9/10 with circuit breaker, eviction, lifecycle hooks, and DI support. |
|
|
Esox.SharpAndRusty.EntityFrameworkCore
Entity Framework Core extensions for Esox.SharpAndRusty Result and Option types. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated | |
|---|---|---|---|
| 1.8.0-rc1 | 71 | 10/20/2026 | |
| 1.7.6 | 88 | 10/18/2026 | |
| 1.7.4 | 101 | 10/16/2026 | |
| 1.7.3 | 99 | 10/16/2026 | |
| 1.7.2 | 94 | 10/12/2026 | |
| 1.7.1 | 95 | 10/11/2026 | |
| 1.7.0 | 111 | 10/2/2026 | |
| 1.6.8 | 120 | 9/22/2026 | |
| 1.6.7 | 186 | 8/18/2026 | |
| 1.6.5 | 130 | 8/12/2026 | |
| 1.6.4 | 150 | 8/9/2026 | |
| 1.6.3 | 122 | 8/8/2026 | |
| 1.6.2 | 160 | 5/28/2026 | |
| 1.6.1 | 136 | 5/24/2026 | |
| 1.6.0 | 167 | 5/15/2026 | |
| 1.5.3 | 144 | 3/22/2026 | |
| 1.5.2 | 136 | 3/20/2026 | |
| 1.5.1 | 231 | 3/14/2026 | |
| 1.5.0 | 145 | 2/14/2026 | |
| 1.4.4 | 147 | 2/8/2026 |