DEV Community

Safwan Abdulghani
Safwan Abdulghani

Posted on

Rust-Style Error Handling in C# - Why SharpResults Makes Your Code Bulletproof

We've all been there. You're debugging a production issue at 2 AM, and somewhere deep in the call stack, someone forgot to catch an exception. The error was invisible in the function signature. No warning. No compile-time check. Just a runtime crash waiting to happen.

public User GetUser(string id)  // Can this throw? Who knows!
{
    return _database.Query(id);  // Spoiler: it can
}
Enter fullscreen mode Exit fullscreen mode

Meanwhile, in Rust land, this doesn't happen. The compiler literally won't let you ignore errors. Every function that can fail says so in its type signature, and you can't compile your code until you handle it.

I wanted that safety in C#. So I built SharpResults.

Why Exceptions Aren't Enough

Don't get me wrong—exceptions have their place for truly unexpected situations.
But when failures are part of normal program flow, exceptions make code harder to reason about, less predictable, and slower to execute:

try
{
    var user = await GetUser(id);
    try
    {
        var orders = await GetOrders(user.Id);
        try
        {
            var total = CalculateTotal(orders);
            return total;
        }
        catch (InvalidOperationException ex)
        {
            // Handle calculation errors
        }
    }
    catch (HttpRequestException ex)
    {
        // Handle API errors
    }
}
catch (NotFoundException ex)
{
    // Handle missing user
}
Enter fullscreen mode Exit fullscreen mode

Pyramid of doom, anyone? And here's the thing: nothing in the type signatures tells you this can fail. You just have to know. Or read the docs. Or learn the hard way in production.

The Rust Way: Errors Are Just Values

Rust takes a different approach. If something can fail, you return a Result:

// Rust
fn divide(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 {
        Err("Cannot divide by zero".to_string())
    } else {
        Ok(a / b)
    }
}
Enter fullscreen mode Exit fullscreen mode

The type signature screams "this can fail!" Now here's the same thing with SharpResults:

// C# with SharpResults
public Result<int, string> Divide(int a, int b)
{
    if (b == 0)
        return "Cannot divide by zero";  // Implicit Err

    return a / b;  // Implicit Ok
}
Enter fullscreen mode Exit fullscreen mode

See the difference? The error is part of the type. The compiler knows about it. Your IDE knows about it. You can't pretend it doesn't exist.

You Literally Can't Ignore Errors

Here's where it gets good. Try to use the result without handling the error:

var result = Divide(10, 0);
int value = result;  // ❌ Compile error!
Enter fullscreen mode Exit fullscreen mode

The compiler won't let you. You have to handle both cases:

var message = result.Match(
    ok: value => $"Result: {value}",
    err: error => $"Error: {error}"
);
Enter fullscreen mode Exit fullscreen mode

Compare that to exceptions:

public int DivideUnsafe(int a, int b)
{
    return a / b;  // Can throw, nothing stops you from ignoring it
}

var value = DivideUnsafe(10, 0);  // 💥 Boom, runtime crash
Enter fullscreen mode Exit fullscreen mode

Multiple Ways to Create Results

SharpResults gives you flexibility in how you create results:

// Explicit factory methods
var ok = Result.Ok<int, string>(42);
var err = Result.Err<int, string>("Something went wrong");

// Implicit conversions (my favorite)
Result<int, string> result1 = 42;  // Becomes Ok
Result<int, string> result2 = "Error";  // Becomes Err

// Catch exceptions automatically
var result3 = Result.Try(() => int.Parse("123"));
var result4 = await Result.TryAsync(async () => await GetDataAsync());

// Convert from Option or functions
var fromOption = Result.From(someOption);
var fromFunc = Result.From(() => DoSomething());

// With custom error types
var result5 = Result.From(
    () => RiskyOperation(),
    ex => new MyError(ex.Message, 500)
);
Enter fullscreen mode Exit fullscreen mode

Working with Results: Your Way

Multiple ways to extract values depending on your needs:

// Safe extraction with defaults
var value = result.UnwrapOr(0);  // Returns value or 0
var value2 = result.UnwrapOrElse(err => err.Length);  // Compute from error
var value3 = result.UnwrapOrDefault();  // Type's default value

// Pattern matching (C# 8+)
var message = result switch
{
    (true, var val, _) => $"Success: {val}",
    (false, _, var err) => $"Error: {err}"
};

// Check state
if (result.WhenOk(out var val))
    Console.WriteLine($"Got {val}");

if (result.WhenErr(out var error))
    Console.WriteLine($"Error: {error}");

// Deconstruction
var (value, error) = result;  // One will be null

// Properties
if (result.IsOk)
    ProcessValue(result.Unwrap());
Enter fullscreen mode Exit fullscreen mode

Chain Operations Without the Pyramid

Remember that nested try-catch nightmare from earlier? Here's the SharpResults version:

public Result<double, string> GetDiscountedPrice(string userId, string productId)
{
    return GetUser(userId)
        .AndThen(user => GetProduct(productId).Map(product => (user, product)))
        .AndThen(data => CalculateDiscount(data.user, data.product))
        .Map(discount => ApplyDiscount(productPrice, discount));
}
Enter fullscreen mode Exit fullscreen mode

Or if you prefer LINQ query syntax:

var result = from user in GetUser(userId)
             from product in GetProduct(productId)
             from discount in CalculateDiscount(user, product)
             select ApplyDiscount(product.Price, discount);
Enter fullscreen mode Exit fullscreen mode

No nesting. No manual error propagation. If any step fails, the error automatically flows through and you handle it once at the end. This is called "railway-oriented programming"—your code either stays on the happy path or switches to the error track.

Option: Never Null Again

Tony Hoare called null references his "billion-dollar mistake." Rust doesn't have null—it has Option<T>. SharpResults brings that too:

// No more null returns
public Option<User> FindUserById(string id)
{
    var user = _users.FirstOrDefault(u => u.Id == id);
    return Option.Create(user);  // Some(user) or None
}

// Multiple ways to create Options
Option<int> opt1 = Option.Some(42);
Option<int> opt2 = Option.None<int>();
Option<int> opt3 = 42;  // Implicit conversion

// Chain operations safely
var displayName = FindUserById(id)
    .Map(u => u.Name)
    .Filter(name => !string.IsNullOrEmpty(name))
    .UnwrapOr("Anonymous");

// Pattern matching (.NET 8+)
if (FindUserById(id) is (true, var user))
    Console.WriteLine($"Found: {user.Name}");

// Filter and transform
var adminOption = FindUserById("123")
    .Filter(u => u.IsAdmin)
    .Map(u => u.Name);
Enter fullscreen mode Exit fullscreen mode

No NullReferenceException. Ever. The type system tracks whether a value might be missing, and the compiler makes sure you handle it.

Safe Collections: No More LINQ Bombs

How many times has this bitten you?

var first = list.First();  // 💥 InvalidOperationException if empty
var value = dict[key];     // 💥 KeyNotFoundException if missing
var single = items.Single();  // 💥 Multiple exceptions possible
Enter fullscreen mode Exit fullscreen mode

SharpResults adds safe alternatives for everything:

// Safe LINQ operations
var first = users.FirstOrNone();  // Option<User>
var last = users.LastOrNone(u => u.IsActive);
var single = items.SingleOrNone(u => u.Id == id);
var element = users.ElementAtOrNone(5);

// Dictionary operations
var value = dict.GetValueOrNone(key);  // Option<TValue>

// Stack and Queue
var peeked = stack.PeekOrNone();  // Doesn't remove
var popped = stack.PopOrNone();  // Removes and returns
var dequeued = queue.DequeueOrNone();

// PriorityQueue (.NET 6+)
var next = priorityQueue.PeekOrNone<Task, int>();
var task = priorityQueue.DequeueOrNone<Task, int>();

// Thread-safe concurrent collections
var item = concurrentBag.TakeOrNone();
var value = concurrentStack.PopOrNone();
var next = concurrentQueue.DequeueOrNone();

// Sets (HashSet, SortedSet, ImmutableHashSet, etc.)
var found = hashSet.GetValueOrNone(searchValue);
var item = sortedSet.GetValueOrNone(value);

// Extract all Some values from a sequence
var values = options.Values();  // IEnumerable<T>

// Transform and filter in one pass
var results = items.SelectWhere(x => 
    x > 0 ? Option.Some(x * 2) : Option.None<int>()
);

// Sequence operations
var allOrNone = options.Sequence();  // Option<IEnumerable<T>>
var listOrNone = options.SequenceList();  // Option<List<T>>
Enter fullscreen mode Exit fullscreen mode

No exceptions. Just explicit presence or absence that the type system tracks.

Result Collection Operations

Work with collections of Results safely:

// Extract all successes
var successes = results.Values();  // IEnumerable<T>

// Extract all failures
var failures = results.Errors();  // IEnumerable<TError>

// Inspect without consuming
var result = GetData()
    .Inspect(data => Console.WriteLine($"Got: {data}"))
    .InspectErr(err => Logger.Error(err));

// Check contents
if (result.Contains(expectedValue))
    Console.WriteLine("Found it!");

if (errorResult.ContainsErr(specificException))
    Console.WriteLine("Expected error occurred");

// Collect results (all must succeed)
var results = ids
    .Select(id => GetUser(id))
    .Collect();  // Result<List<User>, TError>
Enter fullscreen mode Exit fullscreen mode

NumericOption: Math That Can Fail (.NET 7+)

For numeric types, you can do math directly on optional numbers:

NumericOption<int> a = 10;
NumericOption<int> b = 20;

var sum = a + b;        // Some(30)
var product = a * b;    // Some(200)
var difference = a - b; // Some(-10)

NumericOption<int> none = NumericOption<int>.None;
var invalid = a + none; // None - operations propagate None

// All arithmetic operators work
var incremented = ++a;  // Some(11)
var negated = -b;       // Some(-20)

// Numeric checks
bool isPositive = NumericOption.IsPositive(a);  // true
bool isEven = NumericOption.IsEvenInteger(b);   // true

// Safe parsing
var parsed = NumericOption<int>.Parse("42");     // Some(42)
var failed = NumericOption<int>.Parse("nope");   // None

// Numeric functions
var absolute = NumericOption.Abs(negated);  // Some(20)
var max = NumericOption.Max(a, b);          // Some(20)
var clamped = NumericOption.Clamp(a, 0, 100);

// Convert between types
Option<int> regularOption = myNumericOption;  // Implicit
Enter fullscreen mode Exit fullscreen mode

Unit Type: For Void-Like Operations

When you want to return a Result but there's no meaningful success value:

public Result<Unit, string> SaveData(string data)
{
    try
    {
        File.WriteAllText("data.txt", data);
        return Unit.Default;  // Success with no value
    }
    catch (Exception ex)
    {
        return ex.Message;  // Error
    }
}

// Or use Result.From for actions
var result = Result.From(() => File.Delete("temp.txt"));
Enter fullscreen mode Exit fullscreen mode

Option/Result Interop

Convert between Options and Results seamlessly:

// Option to Result
var result = option.OkOr("Value not found");  // Result<T, string>
var result2 = option.OkOrElse(() => new MyError("Missing"));

// Result to Option
var okOption = result.Ok();   // Option<T> - None if Err
var errOption = result.Err(); // Option<TError> - None if Ok

// Transpose nested types
Option<Result<int, string>> optRes = /* ... */;
Result<Option<int>, string> resOpt = optRes.Transpose();

Result<Option<int>, string> resOpt2 = /* ... */;
Option<Result<int, string>> optRes2 = resOpt2.Transpose();

// NumericOption to Result
NumericOption<int> numOpt = 42;
var result = numOpt.OkOr("No number");
Enter fullscreen mode Exit fullscreen mode

Bool Extensions: Conditional Options

Create Options based on boolean conditions:

// Execute function if true
var result = isValid.Then(() => ProcessData());  // Option<T>

// Create Some/None based on condition
var option = hasPermission.ThenSome(userData);  // Some or None

// Async versions
var asyncResult = await isValid.ThenAsync(async () => 
    await LoadDataAsync()
);
Enter fullscreen mode Exit fullscreen mode

Full Async/Await Support

Everything works seamlessly with async, including ValueTask for performance:

// Result async operations
var result = await GetUserAsync(id)
    .MapAsync(async user => await GetProfileAsync(user))
    .AndThenAsync(async profile => await EnrichAsync(profile))
    .MapOrElseAsync(
        mapper: async data => await FormatAsync(data),
        defaultFactory: async err => await GetDefaultAsync()
    );

// Option async operations
var option = await FindUserAsync(id)
    .MapAsync(async user => await user.GetNameAsync())
    .AndThenAsync(async name => await ValidateAsync(name))
    .OrElseAsync(async () => await GetDefaultNameAsync());

// Work with async sequences
await foreach (var value in asyncOptions.ValuesAsync())
{
    await ProcessAsync(value);
}

// Async collection operations
var firstAsync = await asyncSequence.FirstOrNoneAsync();
var filteredAsync = await asyncSequence.FirstOrNoneAsync(x => x > 10);

// Results in async sequences
await foreach (var success in asyncResults.ValuesAsync())
{
    await ProcessSuccessAsync(success);
}

await foreach (var error in asyncResults.ErrorsAsync())
{
    await LogErrorAsync(error);
}
Enter fullscreen mode Exit fullscreen mode

Advanced Patterns

SharpResults supports sophisticated error handling patterns:

// OrElse - try alternatives on error
var result = TryPrimary()
    .OrElse(err => TrySecondary())
    .OrElse(err => TryTertiary());

// Flatten nested results
Result<Result<int, string>, string> nested = GetNestedResult();
Result<int, string> flattened = nested.Flatten();

// Zip multiple results together
var combined = result1.Zip(result2, (a, b) => a + b);

// And/Or for combining results
var both = result1.And(result2);  // Ok only if both Ok
var either = result1.Or(result2); // First Ok or last Err
Enter fullscreen mode Exit fullscreen mode

JSON Serialization: Just Works

Built-in support for System.Text.Json with no configuration:

using System.Text.Json;

// Option serialization
var option = Option.Some(42);
var json = JsonSerializer.Serialize(option);  // "42"

var none = Option.None<int>();
var jsonNone = JsonSerializer.Serialize(none);  // "null"

// Result serialization
var ok = Result.Ok<int, string>(42);
var jsonOk = JsonSerializer.Serialize(ok);  // {"ok":42}

var err = Result.Err<int, string>("Error message");
var jsonErr = JsonSerializer.Serialize(err);  // {"err":"Error message"}

// NumericOption serialization (.NET 7+)
NumericOption<int> numOpt = 100;
var jsonNum = JsonSerializer.Serialize(numOpt);  // "100"

// Deserialization works automatically
var deserializedOpt = JsonSerializer.Deserialize<Option<int>>("42");
var deserializedResult = JsonSerializer.Deserialize<Result<int, string>>("""{"ok":42}""");
Enter fullscreen mode Exit fullscreen mode

JSON Extensions for Safe Parsing

Work with System.Text.Json safely:

using SharpResults.Extensions;

// JsonValue
var number = jsonValue.GetOption<int>();  // Option<int>

// JsonObject
var name = jsonObj.GetPropValue<string>("name");  // Option<string>
var node = jsonObj.GetPropOption("address");      // Option<JsonNode>

// JsonElement
var prop = jsonElement.GetPropOption("field");           // Option<JsonElement>
var value = jsonElement.GetPropOption("id".AsSpan());    // Span support
Enter fullscreen mode Exit fullscreen mode

Real-World API Example

Here's how I actually use this for HTTP APIs:

public record ApiError(string Message, int StatusCode);

public async Task<Result<WeatherData, ApiError>> GetWeatherAsync(string city)
{
    return await Result.TryAsync(async () =>
    {
        using var response = await _client.GetAsync($"api/weather/{city}");

        if (!response.IsSuccessStatusCode)
            return Result.Err<WeatherData, ApiError>(
                new ApiError("API request failed", (int)response.StatusCode)
            );

        var json = await response.Content.ReadAsStringAsync();
        var data = JsonSerializer.Deserialize<WeatherData>(json);

        return data is not null
            ? Result.Ok<WeatherData, ApiError>(data)
            : Result.Err<WeatherData, ApiError>(
                new ApiError("Invalid response data", 500)
            );
    })
    .MapErr(ex => new ApiError(ex.Message, 500));
}

// Usage in a controller
var weather = await GetWeatherAsync("London");

return weather.Match(
    ok: data => Ok(new WeatherResponse(data)),
    err: error => StatusCode(error.StatusCode, error.Message)
);
Enter fullscreen mode Exit fullscreen mode

No unhandled exceptions. Error types are explicit. The compiler ensures you handle both paths.

Bridging Exception-Based Code

You'll still work with libraries that throw exceptions. Result.Try is your bridge:

// Catch exceptions and convert to Result
var result = Result.Try(() => 
    JsonSerializer.Deserialize<User>(json)
);

// Async version
var result = await Result.TryAsync(async () => 
    await httpClient.GetStringAsync(url)
);

// Custom error types
var result = Result.From(
    () => RiskyOperation(),
    ex => new MyError(ex.Message, ex.GetType().Name)
);

// For actions (void methods)
var result = Result.From(() => File.Delete("temp.txt"));
Enter fullscreen mode Exit fullscreen mode

This keeps your domain logic clean while working with the messy real world.

Performance: Actually Pretty Good

Rust's philosophy is "zero-cost abstractions." SharpResults gets pretty close:

  • Structs, not classes - No heap allocations for Result/Option
  • No exception overhead - No stack unwinding costs
  • Aggressive inlining - MethodImpl attributes everywhere
  • Span support - Zero-copy conversions
Result<int, string> result = 42;  // Stack-allocated
ReadOnlySpan<int> span = result.AsSpan();  // No copying

// Convert to enumerable without allocating until iteration
IEnumerable<int> enumerable = result.AsEnumerable();
Enter fullscreen mode Exit fullscreen mode

Why This Actually Matters

Type Safety Prevents Runtime Crashes

// Before: invisible errors
public User GetUser(string id);  // Can throw 5 different exceptions

// After: explicit errors
public Result<User, DatabaseError> GetUser(string id);  // Crystal clear
Enter fullscreen mode Exit fullscreen mode

Composability Beats Brittleness

// Exception version: fragile
try { return Step1(); }
catch { try { return Step2(); } catch { ... } }

// Result version: composable
return Step1()
    .AndThen(Step2)
    .AndThen(Step3);
Enter fullscreen mode Exit fullscreen mode

Compiler Enforcement Beats Documentation

// This compiles, crashes at runtime
var user = GetUser(id);
Console.WriteLine(user.Name);  // 💥

// This won't compile until you handle errors
var result = GetUser(id);
// Must call Match, Unwrap, UnwrapOr, etc.
Enter fullscreen mode Exit fullscreen mode

When To Use What

Use Result:

  • API calls that can fail
  • Database operations
  • File I/O operations
  • Business logic validations
  • Any operation with expected failure modes

Use Option:

  • Instead of returning null
  • Dictionary lookups
  • Finding items in collections
  • Configuration values that might not exist
  • Optional parameters or fields

Still use exceptions:

  • Programming errors (ArgumentNullException)
  • Truly exceptional situations (OutOfMemoryException)
  • Third-party code boundaries you don't control

Getting Started

dotnet add package SharpResults
Enter fullscreen mode Exit fullscreen mode

Start simple:

using SharpResults;

public Result<int, string> ParseAge(string input)
{
    return int.TryParse(input, out var age) && age > 0
        ? age
        : "Invalid age";
}

// Chain operations
var result = ParseAge("25")
    .AndThen(age => ValidateAge(age))
    .Map(age => new User { Age = age });

// Handle both cases
result.Match(
    ok: user => Console.WriteLine($"Created: {user.Age}"),
    err: error => Console.WriteLine($"Failed: {error}")
);
Enter fullscreen mode Exit fullscreen mode

What You Get

  • Result - Rust-style error handling
  • Option - Null safety without nullable types
  • Pattern matching - Deconstruction and switch expressions
  • LINQ integration - Query syntax support
  • Full async/await - Task and ValueTask support
  • Safe collections - FirstOrNone, GetValueOrNone, PopOrNone, etc.
  • NumericOption - Math operations on optional numbers (.NET 7+)
  • Unit type - For void-like operations
  • Zero dependencies - No external packages
  • JSON serialization - Built-in System.Text.Json support
  • Performance - Struct-based, stack-allocated
  • Immutable collections - Support for ImmutableStack, ImmutableQueue, etc.
  • Concurrent collections - Thread-safe operations

Built for .NET 8+, MIT licensed, zero dependencies.

My Honest Take

Is this more verbose than throwing exceptions? Sometimes, yeah. But it's honest verbosity. The error handling is visible. The compiler helps you. And most importantly, you stop getting paged at 2 AM because someone forgot a try-catch.

Rust proved that making errors explicit makes code more robust. SharpResults brings that lesson to C#. Your code becomes more maintainable, your bugs become compile-time errors, and your function signatures actually tell the truth about what can go wrong.

Try it on one feature. See how it feels. Let the compiler guide you. I think you'll find that explicit error handling is actually liberating—not limiting.


Links:

Have you tried similar patterns in production? What's your take on explicit vs exception-based error handling? I'd love to hear your thoughts below! 👇

Top comments (1)

Collapse
 
safwa1_dev profile image
Safwan Abdulghani