Bring exhaustive pattern matching to C# enums and unions with zero boilerplate.
MatchGenerator is a Roslyn source generator that creates Match extension methods for your enums and discriminated-union-like types, enabling concise, expressive, and compile-time safe branching.
- Generate
Matchextension methods for enums and unions - Exhaustive by design (no missing cases)
- Attribute-driven (opt-in per type)
- Works with external types you don't own (via
[assembly: GenerateMatchFor(typeof(T))]) - Supports generic base types (
Option<T>,Either<L, R>, recursiveList<T>, …) - Respects effective accessibility
- Zero runtime cost (pure source generation)
dotnet add package Aigamo.MatchGeneratorTip: To share the reference across every project in a directory, add a
Directory.Build.props. Combined with a globalUsing, this also drops the need for a per-fileusing Aigamo.MatchGenerator;:<Project> <ItemGroup> <PackageReference Include="Aigamo.MatchGenerator" /> <Using Include="Aigamo.MatchGenerator" /> </ItemGroup> </Project>
using Aigamo.MatchGenerator;
[GenerateMatch]
public enum Gender
{
Male = 1,
Female,
}using Aigamo.MatchGenerator;
[GenerateMatch]
abstract record MaritalStatus
{
private MaritalStatus() { }
public sealed record Single : MaritalStatus;
public sealed record Married : MaritalStatus;
public sealed record Divorced : MaritalStatus;
public sealed record Widowed : MaritalStatus;
}Nesting the cases inside the base type and giving it a private constructor makes the hierarchy closed — no case can be declared outside MaritalStatus. The generator qualifies the nested cases by their containing type (MaritalStatus.Single, …) so the generated extension resolves them correctly. Top-level derived types work too; nesting is just a common way to model a closed union.
On C# 15 and later, prefer the language-native closed modifier to constrain the hierarchy directly:
[GenerateMatch]
public closed record MaritalStatus
{
public sealed record Single : MaritalStatus;
public sealed record Married : MaritalStatus;
public sealed record Divorced : MaritalStatus;
public sealed record Widowed : MaritalStatus;
}The package ships an analyzer (AMG003) that reports a warning when a [GenerateMatch] base type is not closed: an open hierarchy can gain a derived type the generated Match never handles, silently dropping a case. The warning only fires when the compiler supports closed (C# 15+), so earlier language versions are unaffected. A quick fix ("Mark base type as closed") adds the closed modifier for you. Relax or disable it in .editorconfig:
dotnet_diagnostic.AMG003.severity = suggestionThe base type can be generic. Its type parameters flow through to the generated Match method, which appends the result type parameter (U) after them:
using Aigamo.MatchGenerator;
[GenerateMatch]
closed record Option<T>
{
private Option() { }
public sealed record Some(T Value) : Option<T>;
public sealed record None : Option<T>;
}Multiple type parameters (Either<L, R>) and recursive definitions (List<T> whose Cons case holds a List<T> tail) work the same way — see the generated code below.
If the enum or union lives in another assembly — so you can't put [GenerateMatch] on it — target it by typeof with an assembly-level attribute instead:
using Aigamo.MatchGenerator;
[assembly: GenerateMatchFor(typeof(DayOfWeek))]Then call Match exactly as you would on an annotated type:
var label = today.Match(
Sunday: () => "Sun",
Monday: () => "Mon",
Tuesday: () => "Tue",
Wednesday: () => "Wed",
Thursday: () => "Thu",
Friday: () => "Fri",
Saturday: () => "Sat"
);The generated method is placed in the target type's namespace and is internal to your assembly (a local convenience, not public API on a type you don't own). Repeat the attribute (it allows multiple) to target several types. This works for external enums, and for unions whose derived types are declared in your own code (cross-assembly derived types are not discovered).
If a target has nothing to match — it isn't an enum and has no derived types in your compilation — the generator reports AMG001 and skips it.
var message = gender.Match(
Male: () => "male",
Female: () => "female"
);var message = maritalStatus.Match(
Single: x => "single",
Married: x => "married",
Divorced: x => "divorced",
Widowed: x => "widowed"
);var message = gender switch
{
Gender.Male => "male",
Gender.Female => "female",
_ => throw new UnreachableException(),
};var message = maritalStatus switch
{
MaritalStatus.Single x => "single",
MaritalStatus.Married x => "married",
MaritalStatus.Divorced x => "divorced",
MaritalStatus.Widowed x => "widowed",
_ => throw new UnreachableException(),
};var message = gender.Match(
Male: () => "male",
Female: () => "female"
);- More concise
- More readable
- No default case required
- Compile-time safety
All cases must be handled.
If a new enum value or union type is added:
public enum Gender
{
Male = 1,
Female,
Other,
}or
public sealed record Separated : MaritalStatus;Existing Match calls will fail to compile until updated. This ensures no cases are missed.
The Match parameters follow declaration order — the order in which the enum members (or union derived types) appear in source — not alphabetical order. This keeps the parameter list append-stable: a case added at the end of the enum becomes the last parameter, so existing positional call sites keep compiling.
Two caveats:
- Inserting or reordering cases in the middle still shifts positions and can silently rebind positional arguments.
- For unions, derived types can be spread across files, so "declaration order" is really source-traversal order and is less strictly append-stable.
Because of this, prefer named arguments (Single:, Married:, …) — they are order-independent and the only fully safe call style across changes. The generated parameter names are designed for exactly this.
To enforce it, the package ships an analyzer (AMG002) that reports an error when Match is called with positional arguments. If you'd rather have it as a warning (or turn it off entirely), relax it in .editorconfig:
dotnet_diagnostic.AMG002.severity = warningBy default, generated parameters are named after the case they handle (Male, Single, …). If you prefer the classic onFoo style, set an MSBuild property in your .csproj (or Directory.Build.props):
<PropertyGroup>
<MatchGeneratorParameterPrefix>on</MatchGeneratorParameterPrefix>
</PropertyGroup>var message = gender.Match(
onMale: () => "male",
onFemale: () => "female"
);The prefix applies project-wide and defaults to empty. Since it's spliced directly into generated parameter names, it must be a valid C# identifier on its own; an invalid value (stray braces, a comment marker, …) is rejected as an error (AMG004) rather than silently falling back to no prefix, since that fallback would otherwise surface as unrelated-looking errors at call sites written for the configured prefix.
Match is a thin wrapper around a switch, and the generated method is marked
[MethodImpl(MethodImplOptions.AggressiveInlining)] so the JIT can fold the dispatch into the
call site. The only runtime cost worth thinking about is the Func<> callbacks:
-
Prefer non-capturing (
static) lambdas. A lambda that captures no local state compiles to a single cached delegate — zero allocation per call. Marking themstaticmakes the compiler enforce that:var message = gender.Match( Male: static () => "male", Female: static () => "female" );
-
A capturing lambda allocates a closure per call.
x => x + localbuilds a new closure object (and delegates) on everyMatchinvocation. That is usually fine, but on a hot path it shows up. Inlining lets the JIT's escape analysis stack-allocate most of it, but avoiding the capture is strictly cheaper.
The repository includes a BenchmarkDotNet project
(Aigamo.MatchGenerator.Benchmarks) that measures this. Run it with:
dotnet run -c Release --project Aigamo.MatchGenerator.Benchmarksinternal static class GenderMatchExtensions
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static U Match<U>(
this Gender value,
Func<U> Male,
Func<U> Female
)
{
return value switch
{
Gender.Male => Male(),
Gender.Female => Female(),
_ => throw new UnreachableException(),
};
}
}internal static class MaritalStatusMatchExtensions
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static U Match<U>(
this MaritalStatus value,
Func<MaritalStatus.Single, U> Single,
Func<MaritalStatus.Married, U> Married,
Func<MaritalStatus.Divorced, U> Divorced,
Func<MaritalStatus.Widowed, U> Widowed
)
{
return value switch
{
MaritalStatus.Single x => Single(x),
MaritalStatus.Married x => Married(x),
MaritalStatus.Divorced x => Divorced(x),
MaritalStatus.Widowed x => Widowed(x),
_ => throw new UnreachableException(),
};
}
}The base type's type parameters are declared on the method, ahead of the result type U, and the cases are qualified by the constructed base (Option<T>.Some):
internal static class OptionMatchExtensions
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static U Match<T, U>(
this Option<T> value,
Func<Option<T>.Some, U> Some,
Func<Option<T>.None, U> None
)
{
return value switch
{
Option<T>.Some x => Some(x),
Option<T>.None x => None(x),
_ => throw new UnreachableException(),
};
}
}Multiple type parameters carry through in declaration order — Either<L, R> produces Match<L, R, U> — and recursive definitions such as List<T> are handled without special-casing:
internal static class ListMatchExtensions
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static U Match<T, U>(
this List<T> value,
Func<List<T>.Empty, U> Empty,
Func<List<T>.Cons, U> Cons
)
{
return value switch
{
List<T>.Empty x => Empty(x),
List<T>.Cons x => Cons(x),
_ => throw new UnreachableException(),
};
}
}- Introducing C# Source Generators - .NET Blog
- roslyn/docs/features/source-generators.cookbook.md at main · dotnet/roslyn
- roslyn/docs/features/incremental-generators.cookbook.md at main · dotnet/roslyn
- Domain Modeling Made Functional: Tackle Software Complexity with Domain-Driven Design and F# by Scott Wlaschin
- It Seems the C# Team Is Finally Considering Supporting Discriminated Unions - DEV Community
- salvois/DiscriminatedOnions: A stinky but tasty hack to emulate F#-like discriminated unions in C#