A C# source generator that creates zero-allocation discriminated unions (tagged unions) as structs with explicit memory layout.
- Zero-allocation unions — generates structs with
[StructLayout(LayoutKind.Explicit)]and[FieldOffset]for compact, union-like memory - Two definition styles — partial struct API for simple cases, record template API for variants with common fields
- Rich generated API — factory methods,
Is*checks, property accessors,TryGet*,Match, equality operators, andToString - Implicit conversions — single-parameter variants with unique types get implicit conversion operators
- Tag enum — generates a nested
Tagsenum for use withswitchexpressions - Compile-time diagnostics — 12 analyzer rules (SU0001–SU0012) catch mistakes at build time
- Wide compatibility — targets netstandard2.0, netstandard2.1, net6.0, net8.0, and net10.0
Add the NuGet package:
<PackageReference Include="StructUnion" Version="*" />The source generator is bundled inside the package and activates automatically.
Define a union:
using StructUnion;
[StructUnion]
public readonly partial struct Shape
{
public static partial Shape Circle(double radius);
public static partial Shape Rectangle(double length, double width);
public static partial Shape Triangle(double @base, double height);
}Use it:
var shape = Shape.Circle(5.0);
// Check variant
if (shape.IsCircle)
Console.WriteLine(shape.CircleRadius); // 5
// Pattern match
var area = shape.Match(
r => Math.PI * r * r,
(l, w) => l * w,
(b, h) => 0.5 * b * h);
// Try-get
if (shape.TryGetCircle(out var radius))
Console.WriteLine(radius);
// Switch on tag enum
var name = shape.Tag switch
{
Shape.Tags.Circle => "circle",
Shape.Tags.Rectangle => "rectangle",
Shape.Tags.Triangle => "triangle",
_ => "unknown"
};
// Equality
var same = Shape.Circle(5.0) == Shape.Circle(5.0); // true
// ToString
Console.WriteLine(shape); // "Circle(5)"Define variants as static partial methods on a readonly partial struct:
[StructUnion]
public readonly partial struct OptionInt
{
public static partial OptionInt Some(int value);
public static partial OptionInt None();
}
// Implicit conversion (single-parameter variant with unique type)
OptionInt opt = 42;
opt.Match(v => Console.WriteLine(v), () => Console.WriteLine("none"));Variants can have zero or more parameters, and support both value types and reference types:
[StructUnion]
public readonly partial struct Payload
{
public static partial Payload Text(string value);
public static partial Payload Number(int value);
public static partial Payload Both(string name, int age);
public static partial Payload Empty();
}Generic type parameters are fully supported:
[StructUnion]
public readonly partial struct Option<T>
{
public static partial Option<T> Some(T value);
public static partial Option<T> None();
}
[StructUnion]
public readonly partial struct Result<TOk, TError>
{
public static partial Result<TOk, TError> Ok(TOk value);
public static partial Result<TOk, TError> Error(TError error);
}
Option<int> opt = Option<int>.Some(42);
Result<string, Exception> result = Result<string, Exception>.Ok("hello");Generic unions use sequential (Auto) layout since type sizes are unknown at generation time. Constraints (where T : struct, where T : class, etc.) are preserved on the generated struct.
Define variants as nested types inside a partial record or partial class. This style supports common fields shared across all variants:
[StructUnion]
public partial record ShapeRecord(int Common)
{
public record Circle(double Radius);
public record Rectangle(double Length, double Width);
public record Triangle(double Base, double Height);
}The generator strips the Record suffix to produce a struct named Shape:
var shape = Shape.Circle(42, 5.0); // common field + variant fields
Console.WriteLine(shape.Common); // 42
Console.WriteLine(shape.CircleRadius); // 5For more complex types with multiple reference-type variants:
[StructUnion]
public partial record JsonValueRecord
{
public record Str(string Value);
public record Num(double Value);
public record Bool(bool Value);
public record Arr(object[] Items);
public record Obj(Dictionary<string, object> Data);
public record Null();
}
var v = JsonValue.Num(3.14);
var result = v.Match(
s => "string",
n => $"number: {n}",
b => "bool",
a => "array",
d => "object",
() => "null");For each union, the generator produces:
| Member | Example | Description |
|---|---|---|
Tags enum |
Shape.Tags.Circle |
Nested enum (: byte) with a member per variant and Default = 0 |
Tag property |
shape.Tag |
Returns the Tags enum value for the active variant |
| Factory method | Shape.Circle(5.0) |
Creates an instance of the variant |
Is* property |
shape.IsCircle |
Returns true if the instance is that variant |
| Property accessor | shape.CircleRadius |
Gets the variant's field value (throws if wrong variant) |
TryGet* method |
shape.TryGetCircle(out var r) |
Returns true and extracts fields via out parameters |
Match<T> |
shape.Match(...) |
Exhaustive pattern match returning T |
Match<TState, T> |
shape.Match(state, ...) |
Stateful match (avoids closure allocations) |
Match (void) |
shape.Match(...) |
Exhaustive pattern match with Action delegates |
== / != |
a == b |
Structural equality by variant tag and field values |
Equals / GetHashCode |
a.Equals(b) |
Implements IEquatable<T> |
ToString |
shape.ToString() |
Formats as "Variant(field1, field2)" |
| Implicit conversion | OptionInt opt = 42; |
For single-parameter variants with unique types |
IsDefault |
shape.IsDefault |
Returns true for default(Shape) (tag 0, no variant) |
Override the generated struct name for record templates:
[StructUnion("Shape")]
public partial record ShapeData
{
public record Circle(double Radius);
public record Rectangle(double Length, double Width);
}
// Generates a struct named "Shape" instead of deriving from "ShapeData"[StructUnion(EnableImplicitConversions = false)]
public readonly partial struct OptionInt { ... }By default, the tag property is named Tag. If this conflicts with a variant or common field name, use a custom name:
[StructUnion(TagPropertyName = "Kind")]
public readonly partial struct Event
{
public static partial Event Tag(string value); // "Tag" variant is now fine
public static partial Event Click(int x, int y);
}
// Usage: event.Kind == Event.Tags.ClickBy default, variant fields are exposed as flat properties like shape.CircleRadius. Enable nested accessors to generate a Cases class with a readonly struct per variant, accessed via As{Variant} properties:
[StructUnion(NestedAccessors = true)]
public readonly partial struct DrawCmd
{
public static partial DrawCmd MoveTo(double x, double y);
public static partial DrawCmd LineTo(double x, double y);
public static partial DrawCmd Close();
}
var cmd = DrawCmd.MoveTo(10, 20);
cmd.AsMoveTo.X // 10
cmd.AsMoveTo.Y // 20
// TryGet returns the variant struct
if (cmd.TryGetMoveTo(out var move))
Console.WriteLine($"{move.X}, {move.Y}");
// Variants can have duplicate field names (both have X, Y)
cmd.AsLineTo.XThe generated Cases class contains a readonly struct per variant:
// Generated:
public static class Cases
{
public readonly struct MoveTo { public double X { get; } public double Y { get; } ... }
public readonly struct LineTo { public double X { get; } public double Y { get; } ... }
}Set project-wide defaults with [StructUnionOptions]. Per-type attributes override these when set:
[assembly: StructUnionOptions(
TemplateSuffix = "Template", // strip "Template" instead of "Record" from template names
TagPropertyName = "Kind", // default tag property name for all unions
EnableImplicitConversions = false, // disable implicit conversions project-wide
NestedAccessors = true)] // enable nested accessors project-wide| ID | Severity | Description |
|---|---|---|
| SU0001 | Error | Struct must be declared as partial |
| SU0002 | Error | Struct must be declared as readonly |
| SU0003 | Error | No variant methods found (at least one required) |
| SU0004 | Error | Variant method must return the containing struct type |
| SU0005 | Error | ref/in/out parameters are not supported on variants |
| SU0006 | Error | Too many variants (maximum 255) |
| SU0007 | Warning | Large struct payload (consider a class-based union above 64 bytes) |
| SU0008 | Error | Duplicate variant names (case-insensitive) |
| SU0009 | Error | Tag property name conflicts with a variant or common field name |
| SU0010 | Error | GeneratedName and TemplateSuffix cannot both be set |
| SU0011 | Error | Variant name is reserved (conflicts with generated Tags enum) |
| SU0012 | Error | Invalid C# identifier for GeneratedName or TagPropertyName |
The generator produces structs with [StructLayout(LayoutKind.Explicit)] where variant fields overlap at calculated offsets, similar to C unions:
- A tag field (
_tag) of the generatedTagsenum (: byte) at offset 0 identifies the active variant (Default = 0, then1+per variant) - Value-type fields are packed with proper alignment after the tag
- Reference-type fields occupy a separate zone (required by the CLR for GC correctness)
- The struct size equals: tag + padding + max(variant payload sizes)
For example, Shape with three double-based variants occupies just 24 bytes: 1 byte tag + 7 bytes padding + 16 bytes payload (2 doubles).
When the generator cannot determine field sizes at compile time — generic type parameters or managed value types (e.g., ValueTuple<string, int>) — it falls back to sequential (Auto) layout. The generated API is identical; only the internal memory strategy differs.
- .NET SDK 10.0 or later (for building and testing)
- Consumers of the NuGet package can target netstandard2.0+, net6.0+, net8.0+, or net10.0+
dotnet build# Run all tests
dotnet test
# Run specific test projects
dotnet test tests/StructUnion.UnitTests
dotnet test tests/StructUnion.GeneratorTests
dotnet test tests/StructUnion.IntegrationTestsThe test suite includes:
- Unit tests — parsing, layout calculation, type classification, naming conventions
- Generator tests — snapshot-based verification of generated code using Verify
- Integration tests — end-to-end functional tests exercising the generated API