NickStrupat.DiscriminatedUnion
1.0.0-beta1
Prefix Reserved
dotnet add package NickStrupat.DiscriminatedUnion --version 1.0.0-beta1
NuGet\Install-Package NickStrupat.DiscriminatedUnion -Version 1.0.0-beta1
<PackageReference Include="NickStrupat.DiscriminatedUnion" Version="1.0.0-beta1" />
<PackageVersion Include="NickStrupat.DiscriminatedUnion" Version="1.0.0-beta1" />
<PackageReference Include="NickStrupat.DiscriminatedUnion" />
paket add NickStrupat.DiscriminatedUnion --version 1.0.0-beta1
#r "nuget: NickStrupat.DiscriminatedUnion, 1.0.0-beta1"
#:package NickStrupat.DiscriminatedUnion@1.0.0-beta1
#addin nuget:?package=NickStrupat.DiscriminatedUnion&version=1.0.0-beta1&prerelease
#tool nuget:?package=NickStrupat.DiscriminatedUnion&version=1.0.0-beta1&prerelease
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
| 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-beta1 | 87 | 6/15/2026 |