NickStrupat.DiscriminatedUnion
A struct-based discriminated union (sum type) for .NET with zero-allocation dispatch and compile-time exhaustive matching. Provides anonymous unions (Du<T1, ..., T16>) and source-generated named unions (DuBase<...>), plus a zero-allocation struct-visitor escape hatch and built-in System.Text.Json support.
Install
| Product | Versions |
|---|---|
| .NET |
README
DiscriminatedUnion
A discriminated union (sum type) for .NET, focused on zero-allocation dispatch and a compact struct footprint — a fixed 24 bytes for 3 or more arms, with the 1- and 2-arm cases specialized to store each arm in its own field.
Du<int, string> du = 42;
var label = du.Match(i => $"int: {i}", s => $"str: {s}");
Install
dotnet add package NickStrupat.DiscriminatedUnion
Targets net10.0. Root namespace DiscriminatedUnion (with DiscriminatedUnion.Extensions for the Pick/When/|/Else extensions and DiscriminatedUnion.Visitors for the visitor interfaces).
Two usage modes
1. Anonymous union — Du<T1, ..., Tn> struct
using DiscriminatedUnion;
Du<int, string> du = "hello"; // implicit conversion per arm
du.Switch(
i => Console.WriteLine($"int {i}"),
s => Console.WriteLine($"str {s}")
);
var len = du.Match(
i => i.ToString().Length,
s => s.Length
);
if (du.TryPick<string>(out var s)) { /* ... */ }
Arities Du<T1> through Du<T1...T16> are generated.
2. Named union — derive from DuBase<...>, source-generated partial
public sealed partial class JsonValue : DuBase<JsonObject, JsonValue[], string, long, double, bool, None>;
The source generator emits the constructors, implicit conversions, equality, and JSON converter for the partial. None is included for representing null JSON.
API surface
| Returns | Exhaustive at compile time? | |
|---|---|---|
Match(f1, …, fn) |
TResult |
Yes — wrong arity is a compile error |
Switch(a1, …, an) |
void |
Yes |
Pick<T>(out T?) (ext) |
residual Du<Du<…rest>, None> |
Yes — residual shape is checked by the compiler |
When<T>(Action<T>) (ext) |
residual Du<Du<…rest>, None> |
Yes |
When<T, R>(Func<T, R>) (ext) |
residual Du<Du<…rest>, R> |
Yes — chain collapses to R |
| pipe operator |
residual Du<Du<…rest>, None> / Du<Du<…rest>, R> / terminator |
Yes — chain ends when all arms are stripped |
Else(Action<object>) (ext) |
None (terminator) |
n/a — catch-all |
TryPick<T>(out T?) |
bool |
No (runtime check per call) |
TryCreate<T>(T value, out Du) |
bool |
No (runtime check per call) |
Accept<TVisitor, TResult>(ref TVisitor) |
TResult |
Visitor's Visit<T> is generic — for arm-agnostic operations |
Accept<TVisitor>(ref TVisitor) |
void |
Same, side-effect-only |
TryCreate<T> runs two passes: exact typeof(T) == typeof(Tn) first, then value is Tn for assignability (so Du<Animal, int>.TryCreate<Dog>(dog, …) succeeds with leftmost-arm-wins on ambiguity). Returns false for null.
default(Du<…>) throws InvalidInstanceException on any operation rather than silently picking arm 0.
Residual extraction — Pick, When, |, Else
Pick, When, and the | operator all strip one arm from a Du. The residual is Du<Du<…rest>, None> — the unhandled arms wrapped one level deep, with None in the second slot acting as the "handled" marker. The 1-armed inner Du<T> once you're down to a single remaining arm is what lets the chain terminate unambiguously: the terminator extension is keyed on the Du<Du<T>, None> shape, which the compiler can distinguish from any 2-armed general receiver. Chaining is compile-time exhaustive — once every arm has been stripped, the residual collapses to plain None.
using DiscriminatedUnion;
using DiscriminatedUnion.Extensions;
Du<int, string, double> du = "hit";
// Pick: out-param style
Du<Du<int, double>, None> rest = du.Pick(out string? matched); // matched = "hit"
// When: invokes handler on match, returns residual
Du<Du<int, double>, None> rest2 = du.When(s => Console.WriteLine(s));
// Pipe operator: chain handlers, one per arm. Terminator collapses to None.
None done = du
| (int i) => Console.WriteLine($"int {i}")
| (string s) => Console.WriteLine($"str {s}")
| (double d) => Console.WriteLine($"dbl {d}");
Else and two | overloads terminate a chain with a catch-all:
None done = du
| (int i) => Console.WriteLine($"int {i}")
| (Else e) => Console.WriteLine($"other: {e.Value}"); // boxed unhandled value
None done2 = du
| (int i) => Console.WriteLine(i)
| () => Console.WriteLine("not an int"); // parameterless catch-all
// Or chain Else off a partial residual:
None done3 = (du | (int i) => Console.WriteLine(i))
.Else(value => Console.WriteLine($"other: {value}"));
These are also available on DuBase<…> subclasses. Du<T, None> (an "optional T") is recognized specially: When/Pick/Else on the value arm collapse straight to None without going through the residual chain.
Func-based residuals — When<R>, | with Func<T, R>
The same surface has a dual that takes Func<T, R> instead of Action<T> and threads the handler's return value through the chain. The residual is Du<Du<…rest>, R> (bare R in the second slot, no wrapper) and the chain collapses to plain R:
Du<int, string, double> du = "hit";
string result = du
.When((int i) => $"int:{i}") // Du<Du<string, double>, string>
.When((string s) => $"str:{s}") // Du<Du<double>, string>
.When((double d) => $"dbl:{d}"); // string
// result == "str:hit"
| works the same way. Func<Else, R> and parameterless Func<R> catch-alls return R directly.
Storage
The layout depends on arm count.
1 and 2 arms are hand-specialized: each arm lives in its own strongly-typed field, plus a one-byte tag. The size is the sum of the arm sizes (plus tag and padding), so it varies with the arms — and value-type arms are never boxed, whatever their size:
struct Du<T1, T2> // sizeof(T1) + sizeof(T2) + tag + padding — varies
├─ T1 field // Du<int, string> -> 16 bytes
├─ T2 field // Du<Guid, DateTime> -> 32 bytes
└─ byte index // Du<decimal, decimal> -> 40 bytes
3 or more arms share one fixed layout — 24 bytes regardless of how many further arms you add:
struct Du<T1, ..., Tn> // 24 bytes total, for n >= 3
├─ UnmanagedStorage (16 bytes) // inline payload for small unmanaged values
└─ object? (8 bytes) // discriminator sentinel + reference for boxed/managed values
For each arm of a 3+-arm union, at construction time:
| Arm value type | Storage strategy | Allocation |
|---|---|---|
Unmanaged ≤ 16 bytes (int, Guid, decimal, DateTime, …) |
Bytes packed inline; reference slot holds a cached Index sentinel |
None |
| Value type with references, or > 16 bytes | Wrapped in a Box<T> record |
One per construction |
| Reference type | Reference stored directly | None |
The cached sentinel array (one per byte index) means the discriminator costs nothing on the inline-storage path. The 1- and 2-arm specializations exist precisely to avoid that Box<T> for the most common arities — at the cost of a struct whose size grows with its arms.
Visitor dispatch (zero allocation)
Accept<TVisitor, TResult> constrains TVisitor to a struct implementing IVisitor<TResult>. The JIT monomorphises the call — no virtual dispatch, no boxing of the visitor, no closure allocation:
internal readonly struct ToJson(Utf8JsonWriter writer, JsonSerializerOptions options) : IVisitor
{
void IVisitor.Visit<T>(T value) => JsonSerializer.Serialize(writer, value, options);
}
du.Accept(new ToJson(writer, options));
The library uses this pattern internally for ToString, GetHashCode, Equals, JSON serialization, and TryPick. Match/Switch allocate only when the delegates you pass capture local state — a non-capturing lambda (like i => i.ToString()) is cached by the compiler in a static field and allocates nothing per call. When you need to thread external state into a hot-path match without a closure, put that state in the fields of a struct IVisitor and use Accept.
JSON
Serialization works out of the box via the included JsonConverter:
Du<int, string> du = 42;
var json = JsonSerializer.Serialize(du); // "42"
var back = JsonSerializer.Deserialize<Du<int, string>>(json);
Named unions (DuBase-derived) get their own generated JsonConverter.
Comparison with other DU libraries
| This lib | OneOf | Dunet | LanguageExt | |
|---|---|---|---|---|
| Shape | struct; 24 bytes at 3+ arms, 1–2 specialized (size varies) |
struct, one field per arm (grows with arity) |
record class per arm |
class (Either/Option/etc.) |
| Allocation for value types | None for 1–2 arms or ≤ 16-byte unmanaged; else one Box |
None (lives in arm field) | One per construction | One per construction |
| Allocation for reference types | None | None | One per construction | One per construction |
| Max arms (anonymous) | 16 | 9 | unlimited | n/a (fixed types) |
| Named unions | DuBase<…> + source gen |
OneOfBase<…> (class) |
Partial record + source gen | n/a |
Match exhaustiveness |
Compile-time (signature) | Compile-time (signature) | Compile-time (signature) | Compile-time |
| Pattern-matching syntax | Match(f1, f2) |
Match(f1, f2) |
record patterns + extension Match |
LINQ-style |
| Visitor escape hatch | Yes (struct, no-alloc) | No | No | No |
| JSON support | Built-in | Via OneOf.Json |
Manual | Via LanguageExt.Newtonsoft.Json |
| Scope | Discriminated unions only | Discriminated unions only | Discriminated unions only | Full functional toolkit |
When to pick this library: value-type-heavy workloads where allocation pressure matters, hot paths that benefit from struct-visitor dispatch, or when you want a footprint that stays fixed at 24 bytes as you add arms past two.
When to pick OneOf: broadest community/ecosystem, you're already on it, or you want one field per arm so debugging shows all slots.
When to pick Dunet: the data is naturally a record hierarchy (algebraic data types, AST nodes), you don't mind heap allocation, you prefer C# pattern-matching syntax over Match lambdas.
When to pick LanguageExt: you want an opinionated functional library beyond just unions (Either, Option, Reader/Writer/State, monadic LINQ, etc.).
Limitations
- Custom
IVisitor<T>implementations that internally dispatch ontypeof(T)won't get a compile-time signal when you add a new arm. UseMatch/Switchfor per-arm logic; reserveAcceptfor arm-agnostic operations (serialize, hash, ToString). TryPick<T>chains andTryCreate<T>callers are runtime-checked — adding an arm won't break their call sites either.Pick/When/|chains, by contrast, are compile-time exhaustive: the residual type changes when you add an arm, breaking stale call sites.Elseand|-with-Action<Else>box value-type arms (the boxed value is exposed asElse.Value, typedobject). Per-arm handlers (Action<T>) stay allocation-free.- Up to 16 arms (configurable via
MaxDuTypesinTemplates/Shared.ttinclude; raises generated assembly size).
Contributing
Bug reports and pull requests are welcome on GitHub. Please open an issue before starting significant work so we can align on approach.
License
MIT
Adoption Assessment
A verified publisher signal is available. Assess fit from the package's supported frameworks, license and dependency graph rather than popularity alone.
Related Guides & Workflows
Learn how to evaluate, integrate, or audit this package in your .NET project.