Esox.SharpAndRusty 1.7.3

There is a newer version of this package available.
See the version list below for details.
dotnet add package Esox.SharpAndRusty --version 1.7.3
                    
NuGet\Install-Package Esox.SharpAndRusty -Version 1.7.3
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Esox.SharpAndRusty" Version="1.7.3" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Esox.SharpAndRusty" Version="1.7.3" />
                    
Directory.Packages.props
<PackageReference Include="Esox.SharpAndRusty" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Esox.SharpAndRusty --version 1.7.3
                    
#r "nuget: Esox.SharpAndRusty, 1.7.3"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Esox.SharpAndRusty@1.7.3
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Esox.SharpAndRusty&version=1.7.3
                    
Install as a Cake Addin
#tool nuget:?package=Esox.SharpAndRusty&version=1.7.3
                    
Install as a Cake Tool

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 Error type with context chaining, metadata, and error categorization
  • ✅ Zero Overhead: Result<T,E> is a readonly struct for minimal allocation pressure
  • ✅ ExtendedResult<T,TE>: Record-based alternative with sealed Success/Failure subtypes for exhaustive C# switch expressions and structural equality
  • ✅ Functional Composition: Chain operations with Map, Bind, MapError, and OrElse
  • ✅ Railway-Oriented Programming: Build flat success/failure pipelines with Bind, Map, Ensure, Tap, MapError, and OrElse (see Railway-Oriented Programming)
  • ✅ Error Accumulation: Validation<T,E> with Apply, TraverseValidation, and Sequence collects all errors instead of stopping at the first
  • ✅ Either and Collection Helpers: Either<L,R>, plus Traverse, Sequence, SequenceAll, and Partition for working with collections of results
  • ✅ Pattern Matching: Use the Match method for elegant success/failure handling
  • ✅ Full Equality Support: Implements IEquatable<T> with proper ==, !=, and GetHashCode()
  • ✅ Implicit Conversions: Concise result and option creation with Result<int, string> r = 42; and Option<int> o = 42;
  • ✅ Safe Value Extraction: ValueOption, ErrorOption, TryGetValue, UnwrapOr, UnwrapOrElse, Expect, and Contains methods
  • ✅ Out-Free Parsing: "123".TryParse<int>() and delegate-based TryParse helpers that return Result<T, Error>
  • ✅ Exception Handling Helpers: Built-in Try and TryAsync for wrapping exception-prone operations in both Result<T,E> and ExtendedResult<T,TE>
  • ✅ Inspection Methods: Execute side effects with Inspect, InspectErr, and Tap
  • ✅ LINQ Query Syntax: Full support for C# LINQ query comprehension with from, select, and more
  • ✅ Collection Operations: Combine and Partition for batch processing
  • ✅ Full Async Support: Async Result<T, E> composition with MapAsync, BindAsync, EnsureAsync, TapAsync, OrElseAsync, and CombineAsync, preserving any error type E
  • ✅ Cancellation Support: Async result operations accept CancellationToken and throw OperationCanceledException when 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

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 Result when steps depend on each other. Use Validation<T,E> when they are independent and the caller benefits from seeing every problem at once.
  • Errors should explain themselves: Error carries 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 Result or Option is silently ignored. You can raise it to an error when your team is ready or turn it off. Unwrap() and Expect() 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 / TryAsync let 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

ESOX1002: Option value must be used

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 found
  • InvalidInput - Invalid data
  • PermissionDenied - Insufficient privileges
  • Timeout - Operation timed out
  • Interrupted - Operation cancelled/interrupted
  • ParseError - Parsing failed
  • Io - I/O error
  • ResourceExhausted - Out of memory, disk full, etc.
  • InvalidOperation - Operation invalid for current state
  • And more... (14 categories total)

Exception to ErrorKind Mapping:

  • FileNotFoundException, DirectoryNotFoundException → NotFound
  • TaskCanceledException, OperationCanceledException → Interrupted
  • FormatException → ParseError
  • OutOfMemoryException → ResourceExhausted
  • TimeoutException → Timeout
  • UnauthorizedAccessException → 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 value
  • new Option<T>.None() - Creates an empty option
  • Option<T> opt = value - Implicit conversion creates Some(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 value
  • option is Option<T>.None - Check if option is empty
  • option 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 - Returns true if the result represents success
  • bool IsFailure - Returns true if the result represents failure
Static Factory Methods
  • Result<T, E> Ok(T value) - Creates a successful result
  • Result<Unit, E> Ok() - Creates a successful result with no value (like Rust's Ok(())); only available when T is Unit
  • Result<T, E> Err(E error) - Creates a failed result
  • Result<T, E> Try(Func<T> operation, Func<Exception, E> errorHandler) - Execute operation with exception handling
  • Task<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 value
  • implicit operator Result<T, E>(E error) - Implicitly creates a failed result from an error
  • Note: When T and E are the same type, the compiler reports an ambiguous conversion. Use explicit Ok() / Err() in that case.
Instance Methods
  • R Match<R>(Func<T, R> success, Func<E, R> failure) - Pattern match on the result, returning a value
  • void Match(Action<T> success, Action<E> failure) - Pattern match for side effects only; throws ArgumentNullException for null actions
  • Option<T> ValueOption() - Get success value as Option<T> (out-free)
  • Option<E> ErrorOption() - Get error value as Option<E> (out-free)
  • bool TryGetValue(out T value) - Try to get the success value
  • bool TryGetError(out E error) - Try to get the error value
  • T UnwrapOr(T defaultValue) - Get value or return default
  • T UnwrapOrElse(Func<E, T> defaultFactory) - Get value or compute default
  • Result<T, E> OrElse(Func<E, Result<T, E>> alternative) - Provide alternative on failure
  • Result<T, E> Inspect(Action<T> action) - Execute action on success value
  • Result<T, E> InspectErr(Action<E> action) - Execute action on error value
Equality Methods
  • bool Equals(Result<T, E> other) - Check equality
  • int GetHashCode() - Get hash code
  • bool operator ==(Result<T, E> left, Result<T, E> right) - Equality operator
  • bool operator !=(Result<T, E> left, Result<T, E> right) - Inequality operator
  • string 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 - true if the result is a Success
  • bool IsFailure - true if the result is a Failure
Static Factory Methods
  • ExtendedResult<T, TE> Ok(T value) - Creates a Success result
  • ExtendedResult<Unit, TE> Ok() - Creates a Success with no value (like Rust's Ok(())); only available when T is Unit
  • ExtendedResult<T, TE> Err(TE error) - Creates a Failure result
  • ExtendedResult<T, TE> Try(Func<T> operation, Func<Exception, TE> errorHandler) - Wraps a synchronous operation; throws ArgumentNullException when either argument is null
  • Task<ExtendedResult<T, TE>> TryAsync(Func<Task<T>> operation, Func<Exception, TE> errorHandler) - Async version of Try
Implicit Conversions
  • implicit operator ExtendedResult<T, TE>(T value) - Converts a value to Success
  • implicit operator ExtendedResult<T, TE>(TE error) - Converts an error to Failure
  • Note: When T and TE are the same type, use explicit Ok() / 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; throws ArgumentNullException for null delegates
  • void Match(Action<T> success, Action<TE> failure) - Pattern match for side effects only; throws ArgumentNullException for null actions
  • bool TryGetValue(out T value) - Try to get the success value
  • bool TryGetError(out TE error) - Try to get the error value
  • T UnwrapOr(T defaultValue) - Get value or return default
  • T UnwrapOrElse(Func<TE, T> defaultFactory) - Get value or compute default
  • ExtendedResult<T, TE> OrElse(Func<TE, ExtendedResult<T, TE>> alternative) - Provide alternative result on failure; throws ArgumentNullException for null alternative
  • ExtendedResult<T, TE> Inspect(Action<T> action) - Execute side effect on success value; throws ArgumentNullException for null action
  • ExtendedResult<T, TE> InspectErr(Action<TE> action) - Execute side effect on error value; throws ArgumentNullException for null action
Equality — Record Semantics
  • Success values are equal when their Value fields are equal (via EqualityComparer<T>.Default)
  • Failure values are equal when their Error fields are equal (via EqualityComparer<TE>.Default)
  • Success and Failure are never equal to each other
  • int GetHashCode() - Hash combines a discriminant (1 for Success, 2 for Failure) with the inner value
  • string 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 error
  • Error New(string message, ErrorKind kind) - Creates an error with a specific kind
  • Error FromException(Exception exception) - Converts an exception to an error (maps 11+ exception types)
Instance Methods
  • Error WithContext(string contextMessage) - Adds context to the error
  • Error WithMetadata(string key, object value) - Attaches metadata (validates types)
  • Error WithMetadata<T>(string key, T value) where T : struct - Type-safe metadata attachment
  • Error WithKind(ErrorKind kind) - Changes the error kind
  • Error CaptureStackTrace(bool includeFileInfo = false) - Captures the current stack trace (configurable)
  • bool TryGetMetadata(string key, out object? value) - Gets metadata
  • bool TryGetMetadata<T>(string key, out T? value) - Type-safe metadata retrieval
  • string GetFullMessage() - Gets the full error chain as a string (depth-limited, circular-safe)
Properties
  • string Message - The error message
  • ErrorKind Kind - The error category (14 predefined kinds)
  • Error? Source - The source error (if chained)
  • string? StackTrace - The captured stack trace
  • bool 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> and Result<T, E> to avoid NullReferenceException
  • ✅ 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/Failure subtypes (returning and void overloads)
    • Equality and hash code (record structural equality)
    • TryGetValue / TryGetError / UnwrapOr / UnwrapOrElse
    • OrElse / Inspect / InspectErr
    • Try / TryAsync including exception-capture and null-argument handling
    • Void Match(Action, Action) — side-effect branching with null-safety
  • 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 attempt
  • TryLockTimeout(TimeSpan) - Lock with timeout
  • LockAsync(CancellationToken) - Async lock
  • LockAsyncTimeout(TimeSpan, CancellationToken) - Async lock with timeout

RwLock<T> Methods:

  • Read() - Acquire read lock (allows concurrent readers)
  • TryRead() - Non-blocking read attempt
  • TryReadTimeout(TimeSpan) - Read with timeout
  • Write() - Acquire exclusive write lock
  • TryWrite() - Non-blocking write attempt
  • TryWriteTimeout(TimeSpan) - Write with timeout
  • IntoInner() - Consume the lock and extract the protected value; safe to call from any thread once all guards are released; returns Result<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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • 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.4 is deprecated because it has critical bugs.
1.7.3 99 10/16/2026 1.7.3 is deprecated because it has critical bugs.
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
Loading failed