Resuption 1.0.0
dotnet add package Resuption --version 1.0.0
NuGet\Install-Package Resuption -Version 1.0.0
<PackageReference Include="Resuption" Version="1.0.0" />
<PackageVersion Include="Resuption" Version="1.0.0" />
<PackageReference Include="Resuption" />
paket add Resuption --version 1.0.0
#r "nuget: Resuption, 1.0.0"
#:package Resuption@1.0.0
#addin nuget:?package=Resuption&version=1.0.0
#tool nuget:?package=Resuption&version=1.0.0
Resuption
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:
- no allocation for the value itself;
- no boxing in normal operations;
- predictable value-type layout;
- explicit failure instead of exception-heavy expected control flow;
- callback APIs that can avoid closures and indirect calls;
- good JIT and NativeAOT behavior;
- 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 | Versions 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. |
-
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 |