Open-source project
domn1995/dunet avatar
domn1995/dunet

Dunet: a C# source generator that gives you discriminated unions

C# discriminated union source generator

888 stars30 forksC#MIT

At a glance

What is it?
Dunet is a Roslyn source generator that turns a partial record with the Union attribute into a discriminated union, complete with exhaustiveness-checked switch expressions, a Match method, implicit conversions and async matching. It is MIT licensed and published on NuGet, and the awkward parts are all in the rules it applies to implicit conversions.
Who is it for?
Adopt Dunet if you write C# and want tagged unions with the compiler checking that your switch handles every case, and if you are prepared to write your unions as partial records with inner variant records. Do not adopt it if your unions need properties that are not unique, or if you need the generated implicit conversions for a variant carrying an interface type, because the generator will not produce them.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 9 days ago.
What is it written in?
Mainly C#, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The problem: C# has no tagged unions

A discriminated union is a value that is exactly one of several shapes, and the type system makes you deal with all of them. F# has them. Rust has them. TypeScript has an approximation. C# has nothing, and the usual workaround is a hierarchy of records with an abstract base class, or a hand rolled type with a tag field, and both are places where the compiler stops helping you.

The cost of the hand rolled version is not the declaration, it is the consumption. When you switch over the value, the compiler cannot tell you that you forgot a case, because the tag is an ordinary field and the compiler does not know that the fields of the record are an exhaustive set of possibilities. You get a runtime exception in a code path that only runs in production, or a silent wrong answer.

Dunet is a source generator that fixes this in the language you already use. The project describes itself as a simple source generator for discriminated unions in C#, and the repository topics name the intentions plainly: exhaustiveness-checking, switch-expressions, tagged-unions and functional.

The audience is C# developers who have written the same awkward hierarchy more than once, and who would rather annotate a record than write a base class and a factory method per variant.

The licence is MIT, the package is on NuGet as dunet, and the last push was on 2026-10-14. The releases are v1.16.2 from 2026-04-25, v1.16.1 from 2026-02-16 and v1.16.0 from 2026-02-14, so the version line is stable and the fixes between them are small.

The mechanism: an attribute on a partial record, variants as inner records

The declaration is the whole API surface, and it is short.

cs
// 1. Import the namespace.
using Dunet;

// 2. Add the `Union` attribute to a partial record.
[Union]
partial record Shape
{
    // 3. Define the union variants as inner partial records.
    partial record Circle(double Radius);
    partial record Rectangle(double Length, double Width);
    partial record Triangle(double Base, double Height);
}

Three things have to be true, and the generator checks all of them. The type is partial, because the generator adds members to it. The variants are declared as inner partial records, nested inside the union, so that the type of a value tells you which variant it is. And the union record itself carries the Union attribute, which is how the generator knows to emit anything at all.

The result is that Shape is a type with three possible shapes, and a value of type Shape is one of Shape.Circle, Shape.Rectangle or Shape.Triangle. The nesting is what makes it a discriminated union in the C# sense: the compiler can tell the cases apart by type, and a switch expression over the value can be checked for completeness.

The generator is a Roslyn source generator, so all of this happens at compile time inside your own project. There is no runtime library doing the work, and the generated code is ordinary C# that you can inspect. The repository layout supports that reading: there is a src/ directory for the generator, a test/ directory, an integration/ directory, a benchmark/ directory, and a samples/ directory with six worked examples, including an AreaCalculator, an ExpressionCalculator, an OptionMonad, a PokemonClient and a Serialization sample.

Exhaustiveness: the switch expression that has to be complete

Here is the payoff, and it is the reason to use a generator rather than a base class.

cs
// Optional: statically import the union for more terse code.
using static Shape;

// 4. Use the union variants.
Shape shape = new Rectangle(3, 4);
// Switch expression is checked for exhaustiveness.
var area = shape switch 
{
    Circle(var radius) => 3.14 * radius * radius,
    Rectangle(var length, var width) => length * width,
    Triangle(var @base, var height) => @base * height / 2,
};
Console.WriteLine(area); // "12"

The switch expression is checked for exhaustiveness, which means that adding a fourth variant to the union and forgetting to handle it in a switch becomes a compile error rather than a runtime surprise. For a code base that models a closed set of states, which is what unions are for, that is the whole value proposition.

The static import of the union is the ergonomic part, and it is optional. with `using static Shape;` the variants can be written as Circle and Rectangle directly, so `new Rectangle(3, 4)` constructs the nested record without repeating the union name. The property patterns in the switch arms destructure the variant, which is why the Circle arm can bind a radius variable rather than a whole record.

The generated code also has to live in the same namespace as the union for the type patterns to bind, which is a real constraint on how you organise a project with many unions.

The Match method and the per variant match methods

A switch expression is not always the right shape. Sometimes you have a value and a single thing you want to know about it, and writing a full switch with a throw arm for every other case is noise.

Dunet generates a Match method as a switch expression alternative:

cs
// 4. Use the union variants.
Shape shape = new Shape.Rectangle(3, 4);
var area = shape.Match(
    circle => 3.14 * circle.Radius * circle.Radius,
    rectangle => rectangle.Length * rectangle.Width,
    triangle => triangle.Base * triangle.Height / 2
);
Console.WriteLine(area); // "12"

Every variant gets a lambda, and the method returns whatever the lambdas return, so the arms must agree on a type. Here the arms bind a whole variant rather than destructuring it, so the property access is written out.

Then there is the more specific form, which the documentation describes as useful when unwrapping a union and you only care about transforming a single variant:

cs
public static bool IsZeroDimensional(this Shape shape) =>
    shape.MatchPoint(
        point => true,
        () => false
    );

For each variant, a match method is generated, with the shape MatchPoint, MatchLine, MatchRectangle and MatchSphere in the example, each taking a lambda for that variant and a parameterless lambda as the fallback for everything else. That is the method you write when the question is about one case, and it reads far better than a switch with a discard arm in each of the other branches.

The cost is that the API surface grows with the number of variants. A union with eight cases gets eight match methods, and a union with a generic parameter or a lot of properties produces a lot of generated code for you to read in IntelliSense.

Implicit conversions, and the four conditions that gate them

This is the part of Dunet with rules attached, and the rules are worth reading before you design a union.

Dunet generates implicit conversions between union variants and the union type if the union meets all of the following conditions: the union has no required properties, no variant's property is an interface type, each non-empty variant has a single property, and each non-empty variant's property is unique within the union.

The example is a Result union whose success is a double and whose failure is an Exception:

cs
[Union]
partial record Result
{
    partial record Success(double Value);
    partial record Failure(Exception Error);
}

With those conversions generated, the calling code stops looking like this:

cs
Result Divide(double numerator, double denominator)
{
    if (denominator is 0d)
    {
        // No need for `new Result.Failure(new InvalidOperationException("..."));`
        return new InvalidOperationException("Cannot divide by zero!");
    }

    // No need for `new Result.Success(...);`
    return numerator / denominator;
}

Two notes qualify the behaviour. Empty variants are ignored when generating implicit conversions, and T and T? are not considered unique. That second note has teeth: two variants holding the same type cannot both be converted implicitly, because the compiler would have no way to choose between them.

If a union satisfies the conditions but you do not want the conversions, you pass `EnableImplicitConversions = false` to the Union attribute, and the generator skips them.

So the implicit conversion is a convenience for the common case of a union of single-value variants, and the escape hatch is per union, not global.

Generics, async matching and the namespace rule

Generic unions work, and the documentation uses an option monad as the example, which is the classic case where a union earns its keep:

cs
[Union]
partial record Option<T>
{
    partial record Some(T Value);
    partial record None();
}

The usage then reads the way an option should, with a Some case carrying a value and a None case that carries nothing, matched in a switch expression over the two.

Async unions are also supported. Dunet generates a MatchAsync() extension method for all Task<T> and ValueTask<T> where T is a union type:

cs
// 4. Asynchronously match any union `Task` or `ValueTask`.
var response = await AskAsync()
    .MatchAsync(
        yes => "Yes!!!",
        no => $"No, {no.Reason}"
    );

The note attached to it is the constraint worth remembering: MatchAsync() can only be generated for namespaced unions. The reason is that the generated extension method has to be placed somewhere, and an extension method for a type that has no namespace cannot be declared outside one. A union declared in the global namespace therefore gets the Match method but not the async one, which is a difference in behaviour that is easy to hit and annoying to diagnose.

The last feature area is serialization. It is enabled with the JsonDerivedType attribute on the union, which is the standard System.Text.Json mechanism for polymorphic types, and the samples directory has a Serialization project for it.

Licence, maintenance and where Dunet is the wrong tool

The licence is MIT, with the licence text in the repository as License.txt, so the generated code in your project carries no obligation beyond the terms of the licence itself.

The repository is not archived and the last push was on 2026-10-14, with the most recent release v1.16.2 from 2026-04-25. The repository layout shows a mature setup for a small project: a solution file, a Directory.Build.props, a global.json pinning the SDK, a .csharpierrc for formatting, a .config/ directory, and separate test, integration and benchmark directories, which is the arrangement you would expect from a generator whose output is only correct if the compiler agrees with it.

Where Dunet is the wrong tool is specific. If your union variants hold properties that are not unique, the implicit conversions are not generated, and if you were relying on those conversions the design will not compile as you wrote it. If a variant needs to hold an interface type, the same applies. If you need a union with a variant that carries several properties and you want implicit conversions, that is out as well, because each non-empty variant must have a single property.

The other boundary is organisational. A source generator changes what your compiler produces, so it sits in your build, and every project that references it pays for the generation step and inherits the generated API. For a library you publish, that means your consumers get a different shape of type than a plain record, which is a compatibility decision rather than a technical one.

The judgement in short: if your C# code models closed sets of states and you want the compiler to enforce that your switches cover them, Dunet is a small, focused generator that does exactly that. Test it on one union first, and check the exhaustiveness error appears, because that check is the reason to take the dependency.

Editorial conclusion

Adopt Dunet if you write C# and want tagged unions with the compiler checking that your switch handles every case, and if you are prepared to write your unions as partial records with inner variant records. Do not adopt it if your unions need properties that are not unique, or if you need the generated implicit conversions for a variant carrying an interface type, because the generator will not produce them. Before you commit, write one union and one switch expression by hand, and check that the exhaustiveness error appears when you remove a case, because that error is the entire reason to use a generator rather than a hand written base class.

Frequently asked questions

How do I install and use Dunet?

Add the package with dotnet add package dunet, import the namespace with using Dunet, and put the Union attribute on a partial record whose variants are inner partial records. A switch expression over the union is then checked for exhaustiveness, and a Match method is generated for the same purpose.

When does Dunet generate implicit conversions between variants and the union?

Only when the union has no required properties, no variant's property is an interface type, each non-empty variant has a single property, and each non-empty variant's property is unique within the union. Empty variants are ignored, and T and T? are not considered unique. You can opt out per union with EnableImplicitConversions = false.

What is the difference between Match and the per variant match methods in Dunet?

Match takes a lambda for every variant of the union and returns their common result type. The specific match methods, such as MatchPoint or MatchRectangle, are generated for each variant and take a lambda for that variant plus a parameterless fallback for every other case, which suits code that only cares about one variant.

Can I match a union that comes back from an async method?

Yes. Dunet generates a MatchAsync() extension method for Task<T> and ValueTask<T> where T is a union type. It can only be generated for namespaced unions, so a union declared in the global namespace does not get the async match method.

What licence is Dunet released under?

MIT, with the licence text in License.txt at the root of the repository, and the package published on NuGet as dunet. The latest release is v1.16.2 from 2026-04-25 and the last push was on 2026-10-14.

Official sources

  1. domn1995/dunet on GitHub
  2. Issues
  3. License: MIT
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/domn1995-dunet.svg)](https://hysenlabs.com/projects/domn1995-dunet)

Was this useful?