Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/domn1995/dunet
C# discriminated union source generator
https://github.com/domn1995/dunet
csharp csharp-sourcegenerator discriminated-unions dotnet fp functional functional-programming union
Last synced: 1 day ago
JSON representation
C# discriminated union source generator
- Host: GitHub
- URL: https://github.com/domn1995/dunet
- Owner: domn1995
- License: mit
- Created: 2022-05-30T18:19:06.000Z (over 2 years ago)
- Default Branch: main
- Last Pushed: 2024-05-23T02:14:40.000Z (6 months ago)
- Last Synced: 2024-05-23T03:26:16.712Z (6 months ago)
- Topics: csharp, csharp-sourcegenerator, discriminated-unions, dotnet, fp, functional, functional-programming, union
- Language: C#
- Homepage:
- Size: 420 KB
- Stars: 476
- Watchers: 7
- Forks: 17
- Open Issues: 4
-
Metadata Files:
- Readme: Readme.md
- License: License.txt
Awesome Lists containing this project
- RSCG_Examples - https://github.com/domn1995/dunet
- csharp-source-generators - Dunet - ![stars](https://img.shields.io/github/stars/domn1995/dunet?style=flat-square&cacheSeconds=604800) ![last commit](https://img.shields.io/github/last-commit/domn1995/dunet?style=flat-square&cacheSeconds=604800) A simple source generator for [discriminated unions](https://en.wikipedia.org/wiki/Tagged_union) in C#. (Source Generators / Functional Programming)
README
# Dunet
[![Build](https://img.shields.io/github/actions/workflow/status/domn1995/dunet/main.yml?branch=main)](https://github.com/domn1995/dunet/actions)
[![Package](https://img.shields.io/nuget/v/dunet.svg)](https://nuget.org/packages/dunet)**Dunet** is a simple source generator for [discriminated unions](https://en.wikipedia.org/wiki/Tagged_union) in C#.
## Install
- [NuGet](https://www.nuget.org/packages/Dunet/): `dotnet add package dunet`
## Usage
```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);
}
``````cs
// 4. Use the union variants.
var 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"
```## Generics
Use generics for more advanced union types. For example, an option monad:
```cs
// 1. Import the namespace.
using Dunet;
// Optional: use static import for more terse code.
using static Option;// 2. Add the `Union` attribute to a partial record.
// 3. Add one or more type arguments to the union record.
[Union]
partial record Option
{
partial record Some(T Value);
partial record None();
}
``````cs
// 4. Use the union variants.
Option ParseInt(string? value) =>
int.TryParse(value, out var number)
? new Some(number)
: new None();string GetOutput(Option number) =>
number.Match(
some => some.Value.ToString(),
none => "Invalid input!"
);var input = Console.ReadLine(); // User inputs "not a number".
var result = ParseInt(input);
var output = GetOutput(result);
Console.WriteLine(output); // "Invalid input!"input = Console.ReadLine(); // User inputs "12345".
result = ParseInt(input);
output = GetOutput(result);
Console.WriteLine(output); // "12345".
```## Implicit Conversions
Dunet generates implicit conversions between union variants and the union type if your union meets all of the following conditions:
- The union has no required properties.
- All variants contain a single property.
- Each variant's property is unique within the union.
- No variant's property is an interface type.For example, consider a `Result` union type that represents success as a `double` and failure as an `Exception`:
```cs
// 1. Import the namespace.
using Dunet;// 2. Define a union type with a single unique variant property:
[Union]
partial record Result
{
partial record Success(double Value);
partial record Failure(Exception Error);
}
``````cs
// 3. Return union variants directly.
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;
}var result = Divide(42, 0);
var output = result.Match(
success => success.Value.ToString(),
failure => failure.Error.Message
);Console.WriteLine(output); // "Cannot divide by zero!"
```## Async Match
Dunet generates a `MatchAsync()` extension method for all `Task` and `ValueTask` where `T` is a union type. For example:
```cs
// Choice.csusing Dunet;
namespace Core;
// 1. Define a union type within a namespace.
[Union]
partial record Choice
{
partial record Yes;
partial record No(string Reason);
}
``````cs
// Program.csusing Core;
using static Core.Choice;// 2. Define async methods like you would for any other type.
static async Task AskAsync()
{
// Simulating network call.
await Task.Delay(1000);// 3. Return unions from async methods like any other type.
return new No("because I don't wanna!");
}// 4. Asynchronously match any union `Task` or `ValueTask`.
var response = await AskAsync()
.MatchAsync(
yes => "Yes!!!",
no => $"No, {no.Reason}"
);// Prints "No, because I don't wanna!" after 1 second.
Console.WriteLine(response);
```> **Note**:
> `MatchAsync()` can only be generated for namespaced unions.## Specific Match
Dunet generates specific match methods for each union variant. This is useful when unwrapping a union and you only care about transforming a single variant. For example:
```cs
[Union]
partial record Shape
{
partial record Point(int X, int Y);
partial record Line(double Length);
partial record Rectangle(double Length, double Width);
partial record Sphere(double Radius);
}
``````cs
public static bool IsZeroDimensional(this Shape shape) =>
shape.MatchPoint(
point => true,
() => false
);public static bool IsOneDimensional(this Shape shape) =>
shape.MatchLine(
line => true,
() => false
);public static bool IsTwoDimensional(this Shape shape) =>
shape.MatchRectangle(
rectangle => true,
() => false
);public static bool IsThreeDimensional(this Shape shape) =>
shape.MatchSphere(
sphere => true,
() => false
);
```## Serialization/Deserialization
```cs
using Dunet;
using System.Text.Json.Serialization;// Serialization and deserialization can be enabled with the `JsonDerivedType` attribute.
[Union]
[JsonDerivedType(typeof(Circle), typeDiscriminator: nameof(Circle))]
[JsonDerivedType(typeof(Rectangle), typeDiscriminator: nameof(Rectangle))]
[JsonDerivedType(typeof(Triangle), typeDiscriminator: nameof(Triangle))]
public partial record Shape
{
public partial record Circle(double Radius);
public partial record Rectangle(double Length, double Width);
public partial record Triangle(double Base, double Height);
}
``````cs
using System.Text.Json;
using static Shape;var shapes = new Shape[]
{
new Circle(10),
new Rectangle(2, 3),
new Triangle(2, 1)
};var serialized = JsonSerializer.Serialize(shapes);
// NOTE: The type discriminator must be the first property in each object.
var deserialized = JsonSerializer.Deserialize(
//lang=json
"""
[
{ "$type": "Circle", "radius": 10 },
{ "$type": "Rectangle", "length": 2, "width": 3 },
{ "$type": "Triangle", "base": 2, "height": 1 }
]
""",
// So we recognize camelCase properties.
new JsonSerializerOptions() { PropertyNameCaseInsensitive = true }
);
```## Pretty Print
To control how union variants are printed with their `ToString()` methods, override and seal the union declaration's `ToString()` method. For example:
```cs
[Union]
public partial record QueryResult
{
public partial record Ok(T Value);
public partial record NotFound;
public partial record Unauthorized;public sealed override string ToString() =>
Match(
ok => ok.Value.ToString(),
notFound => "Not found.",
unauthorized => "Unauthorized access."
);
}
```> **Note**:
> You must seal the `ToString()` override to prevent the compiler from synthesizing a custom `ToString()` method for each variant.
>
> More info: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#built-in-formatting-for-display## Shared Properties
To create a property shared by all variants, add it to the union declaration. For example, the following code requires all union variants to initialize the `StatusCode` property. This makes `StatusCode` available to anyone with a reference to `HttpResponse` without having to match.
```cs
[Union]
public partial record HttpResponse
{
public partial record Success;
public partial record Error(string Message);
// 1. All variants shall have a status code.
public required int StatusCode { get; init; }
}
``````cs
using var client = new HttpClient();
var response = await CreateUserAsync(client, "John", "Smith");// 2. The `StatusCode` property is available at the union level.
var statusCode = response.StatusCode;public static async Task CreateUserAsync(
HttpClient client, string firstName, string lastName
)
{
using var response = await client.PostJsonAsync(
"/users",
new { firstName, lastName }
);var content = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
return new HttpResponse.Error(content)
{
StatusCode = (int)response.StatusCode,
};
}return new HttpResponse.Success()
{
StatusCode = (int)response.StatusCode,
};
}
```## Unwrapping
To bypass exhaustive matching and access a variant directly, use the variant-specific `Unwrap` methods.
This can be useful if you're sure of the underlying value or if you don't care about a potential exception at runtime.
```cs
using Dunet;[Union]
partial record Option
{
partial record Some(T Value);
partial record None;
}
``````cs
Option option1 = new Option.Some(3.14);
var some = option.UnwrapSome();
// You can access `Value` directly here.
Console.WriteLine(some.Value); // Prints "3.14".Option option2 = new Option.None();
// Throws `InvalidOperationException` because the underlying variant is `None`.
var bad = option.UnwrapSome();
```> **Note**:
> Unwrapping is unsafe. Use only when runtime errors are ok.## Stateful Matching
To reduce memory allocations, use the `Match` overload that accepts a generic state parameter as its first argument.
This allows your match parameter lambdas to be `static` but still flow state through:
```cs
using Dunet;
using static Expression;var environment = new Dictionary()
{
["a"] = 1,
["b"] = 2,
["c"] = 3,
};var expression = new Add(new Variable("a"), new Multiply(new Number(2), new Variable("b")));
var result = Evaluate(environment, expression);Console.WriteLine(result); // "5"
static int Evaluate(Dictionary env, Expression exp) =>
exp.Match(
// 1. Pass your state "container" as the first parameter.
state: env,
// 2. Use static lambdas for each variant's match method.
static (_, number) => number.Value,
// 3. Reference the state as the first argument of each lambda.
static (state, add) => Evaluate(state, add.Left) + Evaluate(state, add.Right),
static (state, mul) => Evaluate(state, mul.Left) * Evaluate(state, mul.Right),
static (state, var) => state[var.Value]
);[Union]
public partial record Expression
{
public partial record Number(int Value);
public partial record Add(Expression Left, Expression Right);
public partial record Multiply(Expression Left, Expression Right);
public partial record Variable(string Value);
}
```## Nest Unions
To declare a union nested within a class or record, the class or record must be `partial`. For example:
```cs
// This type declaration must be partial.
public partial class Parent1
{
// So must this one.
public partial class Parent2
{
// Unions must always be partial.
[Union]
public partial record Nested
{
public partial record Variant1;
public partial record Variant2;
}
}
}
``````cs
// Access variants like any other nested type.
var variant1 = new Parent1.Parent2.Nested.Variant1();
```## Samples
- [Area Calculator](./samples/AreaCalculator/Program.cs)
- [Serialization/Deserialization](./samples/Serialization/Program.cs)
- [Option Monad](./samples/OptionMonad/Program.cs)
- [Web Client](./samples/PokemonClient/PokeClient.cs)
- [Recursive Expressions](./samples/ExpressionCalculator/Program.cs)
- [Recursive Expressions with Stateful Matching](./samples/ExpressionCalculatorWithState/Program.cs)## Migration
### Migrating from versions < 1.11.0 to versions >= 1.11.0
From v1.11.0 this library now contains an assembly reference.
By default before this `dotnet add package dunet` will have generated:
```xmlruntime; build; native; contentfiles; analyzers; buildtransitive
all```
When upgrading to dunet v1.11.0, this will need to be simplified to:
```xml```
Otherwise the assembly will not be included for compilation, leading to build failures when trying to reference
the `Dunet` namespace or the `UnionAttribute` class.