Monad.NET integrates with popular .NET frameworks and provides source generators for discriminated unions.
Why this exists: Even C# 14 doesn't include native discriminated unions—a feature available in F#, Rust, Swift, Kotlin, and TypeScript. The proposal (csharplang #8928) is under discussion but has no confirmed release date. Monad.NET.SourceGenerators fills the gap today with zero runtime overhead.
dotnet add package Monad.NET.SourceGeneratorsMark your abstract record or class with [Union] and the generator creates exhaustive pattern matching automatically:
using Monad.NET;
[Union]
public abstract partial record Shape
{
public partial record Circle(double Radius) : Shape;
public partial record Rectangle(double Width, double Height) : Shape;
public partial record Triangle(double Base, double Height) : Shape;
}The generator creates utility methods for your union types:
Shape shape = new Shape.Circle(5.0);
// Match with return value - exhaustive pattern matching
var area = shape.Match(
circle: c => Math.PI * c.Radius * c.Radius,
rectangle: r => r.Width * r.Height,
triangle: t => 0.5 * t.Base * t.Height
);
// Match with side effects
shape.Match(
circle: c => Console.WriteLine($"Circle: r={c.Radius}"),
rectangle: r => Console.WriteLine($"Rectangle: {r.Width}x{r.Height}"),
triangle: t => Console.WriteLine($"Triangle: b={t.Base}, h={t.Height}")
);
// Is{Case} properties - type checking
if (shape.IsCircle)
Console.WriteLine("It's a circle!");
// As{Case}() methods - safe casting (returns Option<T>)
var circleArea = shape.AsCircle()
.Map(c => Math.PI * c.Radius * c.Radius)
.GetValueOr(0);
// Map - transform cases
var doubled = shape.Map(
circle: c => new Shape.Circle(c.Radius * 2),
rectangle: r => new Shape.Rectangle(r.Width * 2, r.Height * 2),
triangle: t => new Shape.Triangle(t.Base * 2, t.Height * 2)
);
// Tap - side effects (null handlers are skipped)
shape.Tap(circle: c => Console.WriteLine($"Logging circle: {c.Radius}"));
// Factory methods - cleaner construction
var circle = Shape.NewCircle(5.0);
var rect = Shape.NewRectangle(4.0, 5.0);// Customize generated code
[Union(
GenerateFactoryMethods = true, // Generate New{Case}() methods (default: true)
GenerateAsOptionMethods = true // Generate As{Case}() methods (default: true, requires Monad.NET)
)]
public abstract partial record MyUnion { ... }Domain Events:
[Union]
public abstract partial record DomainEvent
{
public partial record UserRegistered(Guid UserId, string Email) : DomainEvent;
public partial record OrderPlaced(Guid OrderId, decimal Total) : DomainEvent;
public partial record PaymentReceived(Guid PaymentId, decimal Amount) : DomainEvent;
}
// Exhaustive handling - compiler ensures all cases are covered
void HandleEvent(DomainEvent evt) => evt.Match(
userRegistered: e => SendWelcomeEmail(e.Email),
orderPlaced: e => NotifyWarehouse(e.OrderId),
paymentReceived: e => UpdateLedger(e.PaymentId, e.Amount)
);Expression Trees:
[Union]
public abstract partial record Expr
{
public partial record Literal(int Value) : Expr;
public partial record Add(Expr Left, Expr Right) : Expr;
public partial record Multiply(Expr Left, Expr Right) : Expr;
}
int Evaluate(Expr expr) => expr.Match(
literal: l => l.Value,
add: a => Evaluate(a.Left) + Evaluate(a.Right),
multiply: m => Evaluate(m.Left) * Evaluate(m.Right)
);
// (2 + 3) * 4 = 20
var expr = new Expr.Multiply(
new Expr.Add(new Expr.Literal(2), new Expr.Literal(3)),
new Expr.Literal(4)
);
var result = Evaluate(expr); // 20HTTP Responses:
[Union]
public abstract partial record ApiResponse<T>
{
public partial record Success(T Data) : ApiResponse<T>;
public partial record NotFound(string Message) : ApiResponse<T>;
public partial record ValidationError(IReadOnlyList<string> Errors) : ApiResponse<T>;
public partial record ServerError(Exception Ex) : ApiResponse<T>;
}
IActionResult ToActionResult<T>(ApiResponse<T> response) => response.Match(
success: s => new OkObjectResult(s.Data),
notFound: n => new NotFoundObjectResult(n.Message),
validationError: v => new BadRequestObjectResult(v.Errors),
serverError: e => new ObjectResult(e.Ex.Message) { StatusCode = 500 }
);- Types must be
abstractandpartial - Nested types must inherit from the parent type
- Works with both
recordandclasstypes
The Monad.NET.AspNetCore package provides integration with ASP.NET Core:
dotnet add package Monad.NET.AspNetCoreConvert monad types directly to HTTP responses:
using Monad.NET;
using Monad.NET.AspNetCore;
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
// Option → 200 OK or 404 Not Found
[HttpGet("{id}")]
public IActionResult GetUser(int id)
{
return _userService.FindUser(id)
.ToActionResult("User not found");
}
// Result → 200 OK or error status code
[HttpPost]
public IActionResult CreateUser(CreateUserRequest request)
{
return _userService.CreateUser(request)
.ToCreatedResult($"/api/users/{request.Id}");
}
// Validation → 422 with RFC 7807 ValidationProblemDetails
[HttpPut("{id}")]
public IActionResult UpdateUser(int id, UpdateUserRequest request)
{
return ValidateRequest(request)
.ToValidationProblemResult();
}
// Async support
[HttpGet("{id}/profile")]
public async Task<IActionResult> GetProfile(int id)
{
return await _userService.GetProfileAsync(id)
.ToActionResultAsync();
}
}Catch unhandled exceptions and return consistent Result-style responses:
var app = builder.Build();
app.UseResultExceptionHandler(options =>
{
options.IncludeExceptionDetails = app.Environment.IsDevelopment();
});
app.MapControllers();| Monad | Method | Success | Failure |
|---|---|---|---|
Option<T> |
ToActionResult() |
200 OK | 404 Not Found |
Result<T,E> |
ToActionResult() |
200 OK | Custom status code |
Result<T,E> |
ToCreatedResult(location) |
201 Created | Custom status code |
Result<T,E> |
ToNoContentResult() |
204 No Content | Custom status code |
Validation<T,E> |
ToValidationProblemResult() |
200 OK | 422 with ValidationProblemDetails |
Try<T> |
ToActionResult() |
200 OK | 500 Internal Server Error |
All extensions have async variants (ToActionResultAsync).
The Monad.NET.EntityFrameworkCore package provides integration with EF Core:
dotnet add package Monad.NET.EntityFrameworkCoreUse Option<T> as entity properties with automatic conversion to nullable database columns:
public class User
{
public int Id { get; set; }
public string Name { get; set; } = "";
public Option<string> Email { get; set; } // Stored as nullable varchar
public Option<int> Age { get; set; } // Stored as nullable int
}
// In DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>(entity =>
{
entity.Property(e => e.Email)
.HasConversion(new OptionValueConverter<string>());
entity.Property(e => e.Age)
.HasConversion(new OptionStructValueConverter<int>());
});
}Safely query data with Option-returning methods:
// Returns Option<User> instead of throwing or returning null
var user = await context.Users.FirstOrNoneAsync(u => u.Name == "John");
user.Match(
some: u => Console.WriteLine($"Found: {u.Name}"),
none: () => Console.WriteLine("User not found")
);
// Other query extensions
await context.Users.SingleOrNoneAsync(u => u.Id == id);
await context.Users.ElementAtOrNoneAsync(0);
await context.Users.LastOrNoneAsync(u => u.IsActive);| Method | Description |
|---|---|
FirstOrNone() |
First element or None |
FirstOrNoneAsync() |
Async variant |
SingleOrNone() |
Single element or None (throws if multiple) |
SingleOrNoneAsync() |
Async variant |
ElementAtOrNone(index) |
Element at index or None |
ElementAtOrNoneAsync(index) |
Async variant |
LastOrNone() |
Last element or None |
LastOrNoneAsync() |
Async variant |