Resuption 1.0.0

dotnet add package Resuption --version 1.0.0
                    
NuGet\Install-Package Resuption -Version 1.0.0
                    
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="Resuption" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Resuption" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="Resuption" />
                    
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 Resuption --version 1.0.0
                    
#r "nuget: Resuption, 1.0.0"
                    
#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 Resuption@1.0.0
                    
#: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=Resuption&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=Resuption&version=1.0.0
                    
Install as a Cake Tool

Resuption

Russian version

Option<T> and Result<T, E> for C#, inspired by Rust — designed for hot paths with no boxing, no per-call allocations, and no virtual dispatch.

using Resuption;

using static Resuption.Prelude;

Option<int> ParseInt(string s) =>
    int.TryParse(s, out int value) ? Some(value) : None;

Result<int, string> Div(int a, int b) =>
    b == 0 ? Err("division by zero") : Ok(a / b);

int n = ParseInt("42")
    .Filter(static x => x > 0)
    .UnwrapOr(-1);

if (ParseInt("7").TryUnwrap(out int value)) // if let Some(value) = ...
{
    Console.WriteLine(value);
}

Why this exists

Resuption is not intended to replace the discriminated unions coming to .NET 11.

General-purpose language-level discriminated unions are the better abstraction when you primarily care about modeling domain states, exhaustive matching, API clarity, and integration with the language.

Resuption targets a narrower problem:

small Option/Result values used inside performance-sensitive code where boxing, heap allocations, GC pressure, delegate dispatch, and exception-heavy control flow matter.

The types are value types and are designed to stay on the stack or in registers whenever the JIT can do so.

Typical use cases include:

  • parsers;
  • codecs;
  • protocol handling;
  • tight processing loops;
  • collections and lookup helpers;
  • game/server hot paths;
  • NativeAOT code;
  • APIs where failure is expected and should not be represented by exceptions.

If you need a general algebraic data type, use the language feature.

If you need a tiny success/failure or present/absent value that may be executed millions of times, Resuption is intended for that niche.

What's inside

File Contents
Option.cs Option<T> — most of Rust's std::option API
Result.cs Result<T, E> — most of std::result, plus Ok<T> / Err<E> helpers for type inference
OptionExtensions.cs &mut self-style methods (Take, Insert, Replace, AsMut), Flatten, Transpose, Unzip, nullable bridge
ResultExtensions.cs Flatten, Transpose, AsMut, AsMutErr, AsMutSpan, UnwrapOrThrow
Fn.cs IFunc / IAction — monomorphizable callbacks instead of delegates, plus the TFn overloads for both types
Iter.cs Collect, FilterMap, FindMap, FirstOrNone, SingleOrNone, GetValueOrNone, LINQ query syntax
Factories.cs Option.Some, Option.None, Result.Ok, Result.Err, Result.Try, Prelude
Panic.cs PanicException and cold throw helpers
Unit.cs Unit — the equivalent of ()

The repository also contains:

  • Resuption.Bench — API demonstrations and microbenchmarks;
  • Resuption.Tests — 105 xUnit tests, including memory-layout and allocation checks.

Run it

dotnet run -c Release --project Resuption.Bench

With no arguments both the demo and the benchmarks run. To pick one:

dotnet run -c Release --project Resuption.Bench -- demo

demo runs through the API and prints the results; bench runs the benchmarks only. --help lists the modes.

dotnet test

There are 105 tests.

Besides behavioral correctness, the test suite checks properties that ordinary unit tests usually do not:

  • exact struct sizes;
  • zero allocations through combinator chains;
  • no boxing during equality and hashing;
  • allocation behavior of capturing lambdas;
  • in-place mutation through the &mut self-style accessors, including array elements.

The capturing-lambda test is intentional: without proving that a capturing lambda actually allocates, a zero-allocation test for the static overload would not demonstrate much.

Performance characteristics

Resuption is designed so that the value itself is cheap.

For trivial T and E, Option<T> and Result<T, E> are plain tagged structs. There is no object allocation merely because a value is Some, None, Ok, or Err.

That makes them particularly useful when an operation can fail frequently.

Exceptions are excellent for exceptional failures.

They are much less attractive when failure is part of the normal control flow.

For example, a parser where malformed input is expected should usually not pay for stack unwinding, exception objects, and GC traffic on every failed parse.

A Result<T, E> keeps that outcome in ordinary data instead.

Struct sizes

Measured using Unsafe.SizeOf on x64:

Type Resuption Rust
Option<byte> 2 bytes 2 bytes
Option<int> 8 bytes 8 bytes
Option<long> 16 bytes 16 bytes
Option<string> 16 bytes 8 bytes with niche optimization
Result<int, byte> 8 bytes 8 bytes with a union

The value-type layout is intentionally predictable, but the CLR cannot reproduce every layout optimization Rust performs.

Benchmarks

Measurements from Resuption.Bench on .NET 10 x64: an array of 4096 elements with ~17% of the values absent, best of seven rounds after a warm-up.

Numbers are nanoseconds per operation.

Operation ns/op bytes/op
Option<int> + TryUnwrap 0.41 0
int? 0.43 0
-1 sentinel 0.39 0
Try pattern (out int) 0.25 0
Hand-fused Filter → Map → UnwrapOr on int? 0.65 0
Same chain using IFunc structs 1.22 0
Same chain using static lambdas + explicit state 1.53 0
Same chain using capturing lambdas 11.2 88
Result, 10% of operations fail 1.00 0
Exceptions, 10% of operations throw 81.3 19
Result, every operation fails 0.38 0
Exceptions, every operation throws 872 192

The exact numbers are machine-dependent; the important part is the shape of the result.

The Option value itself is effectively free; the spread across the first four rows is fractions of a nanosecond, which at this scale measures the loop rather than the representation.

A three-combinator chain costs roughly another nanosecond compared with a manually fused branch because the tag is checked several times instead of once.

A capturing lambda introduces heap traffic.

And a thrown exception is orders of magnitude more expensive when failure happens frequently.

This distinction matters:

no exception thrown:
try/catch       ~1.11 ns
Result          ~1.11 ns

exception thrown:
Result          ~0.38 ns,   0 B
throw/catch     ~872 ns,  192 B

A try block itself is not the problem.

Throwing is.

Resuption therefore does not try to replace exceptions globally. Exceptions remain the right mechanism for genuinely exceptional failures.

Result<T, E> is useful when failure is expected enough that making it explicit data is cheaper and clearer.

GC behavior

One of the primary goals of the library is to avoid adding work to the garbage collector.

The normal Option<T> / Result<T, E> path does not require boxing.

Combinators can also be used without allocating delegate closures.

For hot loops this means a pipeline can execute entirely as value operations instead of manufacturing temporary objects that later need to be collected.

For example:

Option<int> option = Some(42);

if (option.TryUnwrap(out int value))
{
    Consume(value);
}

There is no boxed option object here.

Likewise, equality and hashing implementations are written to avoid boxing their payloads where possible.

This is the main reason to use Resuption instead of treating it as merely a different spelling of Nullable<T> or a future discriminated union syntax.

The value proposition is specifically:

predictable value-type representation + allocation-aware APIs + callback forms designed for hot-path code.

Callback APIs

There are three callback styles, in increasing order of verbosity and decreasing overhead:

opt.Map(static x => x * 2);

opt.Map(
    factor,
    static (x, factor) => x * factor);

opt.Map<long, Widen>(default);

1. Delegate

opt.Map(static x => x * 2);

This is the most convenient form.

A non-capturing static lambda is cached and does not allocate per invocation, but delegate calls are not always statically inlineable. Dynamic PGO may optimize them in sufficiently hot code.

Use this by default unless profiling says otherwise.

2. Static lambda with explicit state

opt.Map(
    factor,
    static (x, factor) => x * factor);

Nearly every callback-based combinator has an overload that accepts explicit state; the exceptions are ZipWith, TakeIf, and the Iter helpers, which allocate anyway.

This allows stateful operations without creating a closure.

Instead of:

opt.Map(x => x * factor);

which captures factor, use:

opt.Map(
    factor,
    static (x, factor) => x * factor);

The state becomes an ordinary argument rather than a heap-allocated closure object.

3. IFunc struct

For the lowest-overhead path:

readonly struct AtLeast(int bound) : IFunc<int, bool>
{
    public bool Invoke(int x) => x >= bound;
}

opt.Filter(new AtLeast(10));

There is no delegate object, no virtual dispatch, and no closure.

Because the callback type is a generic type parameter, the runtime can specialize the method for that exact callback type.

This is especially useful in:

  • very hot loops;
  • library internals;
  • NativeAOT;
  • environments where dynamic PGO cannot be relied upon.

A chain such as:

Some(x + 1)
    .Filter(new AtLeast(10))
    .Map<long, Widen>(default)
    .UnwrapOr(0);

can compile down to roughly ten machine instructions with the value remaining entirely in registers and without stack traffic.

The exact codegen naturally depends on runtime version and surrounding code.

Why not just use exceptions?

Sometimes you absolutely should.

Exceptions are ideal for situations that are genuinely exceptional:

File.Open(path);

A disk failure, permission issue, or corrupted runtime state is usually not a branch that executes on every iteration of a hot loop.

For expected outcomes, the economics are different.

Consider parsing:

Result<int, ParseError> Parse(ReadOnlySpan<char> input);

If invalid input is common, representing failure as a value:

  • avoids constructing exception objects;
  • avoids stack unwinding;
  • avoids exception-related GC pressure;
  • makes the failure path explicit in the type;
  • remains cheap even if millions of operations fail.

Resuption is therefore not an “exceptions are bad” library.

It is a library for cases where throwing an exception for an expected branch is unnecessarily expensive.

What the CLR cannot do

Rust gets several representation optimizations that ordinary CLR structs cannot reproduce.

Niche optimization

Rust can represent:

Option<&T>

using a single machine word because null can represent None.

The CLR does not expose a general-purpose equivalent for generic structs.

Consequently:

Option<string> = reference + tag/padding

and takes 16 bytes on x64 rather than 8.

Union layout for Result

Rust can generally represent:

Result<T, E>

as something close to:

max(sizeof(T), sizeof(E)) + discriminant

The CLR prevents explicit-layout unions from overlapping managed references with arbitrary fields.

A fully generic managed Result<T, E> therefore cannot safely use the same representation.

Resuption stores both fields plus the tag.

The ? operator

C# has no general equivalent of Rust's ? operator for user-defined result types.

The closest forms are AndThen chains:

ParseA(input)
    .AndThen(ParseB)
    .AndThen(ParseC);

or LINQ query syntax:

var result =
    from a in ParseA(input)
    from b in ParseB(a)
    from c in ParseC(b)
    select c;

Each step short-circuits on the first None or Err.

Two intentional differences from Rust

default(Result<T, E>) is an inert third state

In Rust, a Result<T, E> cannot simply appear as zero-initialized memory.

In C#, it can:

default(Result<T, E>)

It can also appear inside:

  • zero-initialized arrays;
  • uninitialized fields;
  • generic storage.

Treating that value as:

Err(default(E))

would be dangerous.

For example, with:

Result<int, string>

the runtime could silently manufacture:

Err(null)

even though no error value was ever supplied.

Resuption therefore keeps the zero-initialized state distinct.

For default(Result<T, E>):

result.IsOk  == false;
result.IsErr == false;

Operations that are total, such as:

  • UnwrapOr;
  • Ok();
  • Err();
  • equality;

treat it as “no active variant”.

Operations that would otherwise have to invent a T or E value panic instead.

Option<T> does not have this problem:

default(Option<T>)

is naturally None.

No implicit T → Option<T> conversion

There is deliberately no conversion like:

Option<string> value = text;

because then:

Option<string> value = null;

would silently mean:

Some(null)

Some is therefore explicit, just as it is in Rust:

Option<string> value = Some(text);

Mutable access

Several APIs mirror Rust's &mut self operations.

For example:

opt.AsMut() = 42;

or:

foreach (ref var value in opt.AsMutSpan())
{
    value++;
}

The payload is modified in place rather than copied out and written back.

C#'s ref-safety rules also prevent the reference from escaping beyond the lifetime of the option.

Readonly storage is covered too: invoking a mutable operation through a readonly field is a compile error (CS0192/CS0199), just as escaping the reference is (CS8168/CS8347).

Span and enumeration support

AsSpan() mirrors Rust's Option::as_slice.

It returns a span containing either zero or one elements:

ReadOnlySpan<T> values = option.AsSpan();

The span points directly into the option storage.

There is no intermediate array.

Likewise:

foreach (var value in option)
{
    Use(value);
}

uses a struct enumerator and does not allocate.

Mutable enumeration is also available:

foreach (ref var value in option.AsMutSpan())
{
    Mutate(ref value);
}

Avoiding copies of large values

For larger structs:

ref readonly T value = ref option.UnwrapRef();

returns a readonly reference to the payload rather than copying T.

This can matter when T itself is a relatively large value type.

Panic behavior

Panic-style operations intentionally mirror Rust terminology.

For example, calling:

option.Unwrap();

on None throws a PanicException with the familiar message:

called `Option::unwrap()` on a `None` value

The throw paths are moved into cold helper methods so that the normal successful path stays small and easy for the JIT to optimize.

Crossing the exception boundary

Sometimes a Result-based API needs to call exception-based code:

Result<T, Exception> result =
    Result.Try(() => SomethingThatMayThrow());

And sometimes the caller wants to return to the exception world:

T value = result.UnwrapOrThrow();

UnwrapOrThrow() rethrows the captured exception while preserving its original stack trace using ExceptionDispatchInfo.

This makes it possible to use Result internally in performance-sensitive code without forcing the entire application to adopt result-based error handling.

LINQ query syntax

Both Option<T> and Result<T, E> support LINQ query syntax.

That provides something reasonably close to Rust's early-propagation style:

Result<int, string> result =
    from a in ParseA(input)
    from b in ParseB(a)
    from c in ParseC(b)
    select a + b + c;

The query short-circuits at the first Err.

The same applies to Option<T> and None.

This is primarily a readability feature; in extremely hot code, the explicit combinator or fused form may still produce better codegen.

Design philosophy

Resuption tries to preserve the useful parts of Rust's Option and Result model without pretending that the CLR has Rust's type system or layout rules.

The priorities are:

  1. no allocation for the value itself;
  2. no boxing in normal operations;
  3. predictable value-type layout;
  4. explicit failure instead of exception-heavy expected control flow;
  5. callback APIs that can avoid closures and indirect calls;
  6. good JIT and NativeAOT behavior;
  7. familiar Rust-inspired API semantics.

It is deliberately not an attempt to turn C# into Rust.

And it is not intended to compete with .NET 11 discriminated unions as a general-purpose language feature.

Think of it instead as a low-level building block for places where this distinction matters:

ordinary application/domain modeling
    → use idiomatic C# and language-level discriminated unions

performance-sensitive expected branching
    → Option<T> / Result<T, E> can be useful

truly exceptional failure
    → throw an exception

Use the tool that matches the semantics and the cost model of the code.

Product Compatible and additional computed target framework versions.
.NET 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.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0 97 8/30/2026