NickStrupat.DiscriminatedUnion 1.0.0-beta1

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

DiscriminatedUnion

NuGet Downloads License: MIT

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 on typeof(T) won't get a compile-time signal when you add a new arm. Use Match/Switch for per-arm logic; reserve Accept for arm-agnostic operations (serialize, hash, ToString).
  • TryPick<T> chains and TryCreate<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.
  • Else and |-with-Action<Else> box value-type arms (the boxed value is exposed as Else.Value, typed object). Per-arm handlers (Action<T>) stay allocation-free.
  • Up to 16 arms (configurable via MaxDuTypes in Templates/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 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-beta1 87 6/15/2026