Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
e77296f
Switch to using develop branch for prereleases (#7)
christiandaley Aug 2, 2025
4197ef3
fix tag job (#9)
christiandaley Aug 2, 2025
06fb5a4
Auto-merge main into develop [skip ci]
christiandaley Aug 2, 2025
eabb97b
Auto-merge main into develop [skip ci]
christiandaley Aug 2, 2025
995162b
make main mainline
christiandaley Aug 2, 2025
33cba1b
Auto-merge main into develop [skip ci]
invalid-email-address Aug 3, 2025
e52baf1
Auto-merge main into develop [skip ci]
invalid-email-address Aug 3, 2025
582d5bd
Auto-merge main into develop [skip ci]
invalid-email-address Aug 3, 2025
b4b7362
Auto-merge main into develop [skip ci]
invalid-email-address Aug 3, 2025
9fa3970
Auto-merge main into develop [skip ci]
invalid-email-address Aug 3, 2025
65bf838
Auto-merge main into develop
invalid-email-address Aug 3, 2025
bf7776f
Auto-merge main into develop
invalid-email-address Aug 3, 2025
192110c
Auto-merge main into develop
invalid-email-address Aug 3, 2025
abeed8a
Auto-merge main into develop [skip ci]
invalid-email-address Aug 3, 2025
d00590e
Prevent object boxing of unconstrained generic types (#12)
christiandaley Aug 3, 2025
7836e06
Add default match handling (#14)
christiandaley Aug 4, 2025
c68f7dc
add analyzer with non-exhaustive match warning (#15)
christiandaley Aug 4, 2025
5c68ef9
fix project reference (#16)
christiandaley Aug 4, 2025
344fb14
Add unnamed parameter warning (#17)
christiandaley Aug 5, 2025
a631736
Replace switch with match (#18)
christiandaley Aug 5, 2025
04f84a4
Auto-merge main into develop [skip ci]
invalid-email-address Aug 5, 2025
7c4bcea
use fullyqualified type names (#19)
christiandaley Aug 31, 2025
3b849fc
add system. namespace specification (#23)
christiandaley Jul 29, 2026
92a7c0f
Dont rely on implicit usings (#24)
christiandaley Jul 29, 2026
c890acb
fix case naming bug (#25)
christiandaley Jul 29, 2026
3bfaf2d
Auto-merge main into develop [skip ci]
invalid-email-address Jul 29, 2026
4bbac34
Upgrade to dotnet 10 (#27)
christiandaley Jul 30, 2026
fc82293
update publish job to use dotnet 10 (#28)
christiandaley Jul 30, 2026
1549798
Add idisposable and iasyncdisposable implementations (#29)
christiandaley Jul 30, 2026
959e535
Add equals operators for underlying value types (#30)
christiandaley Aug 2, 2026
0cf9518
add support for .NET 11 union types
christiandaley Aug 8, 2026
cd9bf73
fix accessibility of case structs (#32)
christiandaley Aug 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
matrix:
# Build is broken on ubuntu-latest
os: [windows-latest, macos-latest]
dotnet: ["8.0.x", "9.0.x"]
dotnet: ["10.0.x"]

steps:
- name: Checkout
Expand All @@ -26,10 +26,16 @@ jobs:
dotnet-version: ${{ matrix.dotnet }}

- name: Restore dependencies
run: dotnet restore
# run: dotnet restore
run: |
dotnet restore ./Tests/Tests.csproj
dotnet restore ./Tests.AOT/Tests.AOT.csproj

- name: Build solution
run: dotnet build --configuration Release --no-restore
# run: dotnet build --configuration Release --no-restore
run: |
dotnet build ./Tests/Tests.csproj --configuration Release --no-restore
dotnet build ./Tests.AOT/Tests.AOT.csproj --configuration Release --no-restore

- name: Run unit tests
run: dotnet test ./Tests/Tests.csproj --configuration Release --no-build --verbosity normal
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
dotnet-version: "10.0.x"

- name: Restore dependencies
run: dotnet restore SumSharp/SumSharp.csproj
Expand Down
121 changes: 110 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,23 @@ A highly configurable C\# discriminated union library

---

1. [Why use `SumSharp`?](#why-use-sumsharp)
1. [Why use SumSharp?](#why-use-sumsharp)
2. [Installation](#installation)
3. [Quick start](#quick-start)
- [Creating a DU type](#creating-a-du-type)
- [Empty cases](#empty-cases)
- [Generic cases](#generic-cases)
- [The `Match` function](#the-match-function)
- [.NET 11 union types and pattern matching](#net-11-union-types-and-pattern-matching)
4. [Motivation](#motivation)
- [SumSharp vs .NET 11 union types](#sumsharp-vs-net-11-union-types)
- [What about `OneOf`?](#what-about-oneof)
- [Typical DU implementation approaches](#typical-du-implementation-approaches)
- [SumSharp's approach](#sumsharps-approach)
5. [Usage Guide](#usage-guide)
- [Controlling the memory layout](#controlling-the-memory-layout)
- [ValueTuple cases](#valuetuple-cases)
- [IDisposable and IAsyncDisposable cases](#idisposable-and-iasyncdisposable-cases)
- [Struct union types](#struct-union-types)
- [Generic interface types](#generic-interface-types)
- [JSON serialization](#json-serialization)
Expand All @@ -38,18 +41,20 @@ A highly configurable C\# discriminated union library

Discriminated unions, also known as sum types, are an invaluable tool for working with heterogenous data types in code. They help ensure safe data access patterns and can [make illegal states unrepresentable.](https://fsharpforfunandprofit.com/posts/designing-with-types-making-illegal-states-unrepresentable/)

There are many discriminated union libraries available for C\#, such as [`OneOf`](https://github.com/mcintyre321/OneOf) which has received tens of millions of downloads. In my experience, all of them lack features commonly offered by discriminated union types in other languages.
There are many discriminated union libraries available for C\#, such as [`OneOf`](https://github.com/mcintyre321/OneOf) which has received tens of millions of downloads. In my experience, all of them lack features commonly offered by discriminated union types in other languages. Union types are being added to C# with the [.NET 11 release](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/union), but these are not true DUs because they lack case names and thus cannot support multiple cases of the same type.

`SumSharp` aims to be **the most powerful, expressive, and configurable C\# discriminated union library available**. Its goal is to provide features and syntax comparable to the discriminated union types natively offered by languages such as F\#, Rust, and Haskell. Although it's impossible to exactly replicate the functionality these other languages offer, `SumSharp` strives to get as close as possible.

### Features

- **Integration with .NET 11 union types, allowing use of C#'s built-in pattern matching syntax**
- Unlimited number of cases
- Support for class, struct, record, and record struct unions
- Support for generic unions
- Expressive match syntax with exhaustiveness checking
- Implicit conversions from types (if there's only one case of that type in the union)
- Convenient handling of tuple types
- Automatic implementation of `IDisposable` and `IAsyncDisposable` interfaces
- **Highly configurable memory layout**, allowing developers to optimize for their app's memory/perfomance requirements
- Built in JSON serialization with both `System.Text.Json` and `Newtonsoft.Json`. Compatible with `System.Text.Json` source generation and AOT compilation
- Implicit conversions to/from `OneOf` types
Expand Down Expand Up @@ -86,6 +91,7 @@ partial class StringOrDouble

That's it! `SumSharp` will generate members for the `StringOrDouble` class that allow it to be used as a discriminated union type. These members include:

- `Value` and `HasValue` properties, and `TryGetValue` methods to satisfy requirements for a non-boxing .NET 11 union type
- `String` and `Double` static functions that construct instances of `StringOrDouble`
- `AsString` and `AsDouble` properties that return either the underlying string/double value or throw an `InvalidOperationException`
- `IsString` and `IsDouble` boolean properties
Expand Down Expand Up @@ -139,7 +145,7 @@ Case types can be generic. To define a generic case you must supply the **name**
```csharp
[UnionCase("Some", "T")]
[UnionCase("None")]
partial class Optional<T>
partial class Option<T>
{

}
Expand All @@ -149,20 +155,22 @@ Note that generic types in general *must be fully qualified names unless you hav

### The `Match` function

`SumSharp` unions have a `Match` member function that provides functionality similar to the match statement in F\# (with the limitation that `SumSharp` does not offer partial matching). The parameters to `Match` are the handler functions for each case, in order. Each parameter has the same name as its corresponding case, allowing the use of named parameters to improve code readability and for the handlers to be specified out of order. To illustrate this, compare the syntax of performing a match on the `Optional<T>` type defined in the last section to equivalent F\# code.
**If you are using .NET 11 or higher, `SumSharp` unions satisfy the compiler's requirements for a union type. In most cases using built-in C# pattern matching will be easier than using the `Match` function. See [.NET 11 union types and pattern matching](#net-11-union-types-and-pattern-matching)**

`SumSharp` unions have a `Match` member function that provides functionality similar to the match statement in F\# (with the limitation that `SumSharp` does not offer partial matching). The parameters to `Match` are the handler functions for each case, in order. Each parameter has the same name as its corresponding case, allowing the use of named parameters to improve code readability and for the handlers to be specified out of order. To illustrate this, compare the syntax of performing a match on the `Option<T>` type defined in the last section to equivalent F\# code.

```csharp
// Here myOptionalValue is an Optional<string>
// Here myOptionValue is an Option<string>
// The "None" handler can come before the "Some" handler as long as they're both named
var result = myOptionalValue.Match(
var result = myOptionValue.Match(
None: () => "",
Some: x => x);
```

Corresponding F\# code would look like:

```fsharp
let result = match myOptionalValue with
let result = match myOptionValue with
| None -> ""
| Some x -> x
```
Expand All @@ -172,26 +180,68 @@ Handling each case is not required, but a warning will be emitted by the `SumSha
If you only want to handle some subset of cases, you can provide a default handler to prevent a warning from being emitted.

```csharp
var result = myOptionalValue.Match(
var result = myOptionValue.Match(
Some: x => x,
_: () => "");
```

Again, the corresponding F\# code would look like:

```fsharp
let result = match myOptionalValue with
let result = match myOptionValue with
| Some x -> x
| _ -> ""
```

The `SumSharp` analyzer will emit a warning if a default handler is provided for a `Match` that is already exhaustive. It will also emit a warning if any case handlers are specified by position rather than name. Specifying by name is preferred because it makes the code clearer and prevents bugs/compilation errors if the case ordering changes.

### .NET 11 union types and pattern matching

If you are using .NET 11 or higher, `SumSharp` unions satisfy the compiler's requirements for a union type. All `SumSharp` unions implement [the non-boxing access pattern](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/union#non-boxing-access-pattern) and [union member providers](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/union#union-member-providers).

Because C# union types do not support case names, `SumSharp` generates wrapper structs for each case in the union and places them as the same namespace/nested type level as the union itself. Empty cases get empty `partial` structs. This means that two `SumSharp` unions that share the same namespace/type heirarchy **cannot share identical non-empty case names**. These structs are used when pattern matching using built-in C# syntax such as `switch` or `is`. An example using the `Option<T>` type that was defined above:

```csharp
var x = Option<int>.Some(4);

var value = x switch
{
Some<int>(var i) => i,
None => 0,
};

// prints "value is 4"
Console.WriteLine($"value is {value}");

// prints "x is 4"
if (x is Some<int>(4))
{
Console.WriteLine("x is 4");
}
else if (x is None)
{
Console.WriteLine("x is none");
}

```

#### Type union implementation details

* `SumSharp` unions are *never null*. A non-null `SumSharp` union will never match with the `null` pattern, even if the underlying data it stores is null.
* The `IUnionMembers.Value` property is never null and will always return a boxed instance of one of the case structs.
* The `IUnionMembers.HasValue` property always returns true.
* The various `TryGetValue` overloads will wrap the underlying data in one of the case structs.
* `SumSharp` unions implement their corresponding `IUnionMembers` interface explicitly. This means that the `Value` and `HasValue` properties and the `TryGetValue` methods cannot be used unless you explicitly cast it to an `IUnionMembers`. In general you should not need to use any of these: they exist to satisfy the compiler's requirements for custom union types.

---

## Motivation

C\# unfortunately does not offer discriminated unions as a language feature. Although [a proposal](https://github.com/dotnet/csharplang/blob/18a527bcc1f0bdaf542d8b9a189c50068615b439/proposals/TypeUnions.md) has existed for a while, this feature doesn't seem to be coming in the near future.
### `SumSharp` vs .NET 11 union types

The union types introduced by .NET 11 are not true disrciminated unions because they lack the ability to define case names, thus not allowing for multiple cases of the same type. They also always box value types by default, which is unnecessary and often undesireable. They do, however, provide highly convenient pattern matching syntax using C\#'s built-in pattern matching operations such as `switch` and `is`.

As mentioned in the quick start guide, `SumSharp` unions satisfy the requirements for .NET 11 union types. Wrapper structs are defined for each case, allowing for pattern matching behavior that is similar to languages with first class DUs such as F\#. Thus, `SumSharp` works synergistically with C\#'s unions types. You don't need to choose between the two: using `SumSharp` gives you the best of both.

### What about `OneOf`?

Expand Down Expand Up @@ -437,6 +487,53 @@ x.IfCase0((i, s) =>

Custom field names of tuple types will be preserved when accessed via `As[CaseName]`.

### IDisposable and IAsyncDisposable cases

If any case holds a type that implements `IDisposable` and/or `IAsyncDisposable`, the union itself will also implement the `IDisposable` and/or `IAsyncDisposable` interfaces, respectively. Additionally, if any case holds a generic type the union will always implement both `IDisposable` and `IAsyncDisposable`.

```csharp

class Disposable : IDisposable
{
// ...
}

class AsyncDisposable : IAsyncDisposable
{
//..
}

[UnionCase("Case0", typeof(Disposable))]
[UnionCase("Case1", typeof(AsyncDisposable))]
partial class DisposableOrAsyncDisposable
{
// DisposableOrAsyncDisposable implements both IDisposable and IAsyncDisposable
}

// ..
{
using DisposableOrAsyncDisposable w = new Disposable();
} // w.Dispose() will be called, which in turn will call Dispose() on the underlying Disposable

{
await using DisposableOrAsyncDisposable x = new AsyncDisposable();
} // x.DisposeAsync() will be called, which in turn will call DisposeAsync() on the underlying AsyncDisposable

{
await using DisposableOrAsyncDisposable y = new Disposable();
} // y.DisposeAsync() will be called, which in turn will call Dispose() on the underlying Disposable

{
using DisposableOrAsyncDisposable z = new AsyncDisposable();
} // z.Dispose() will be called, which WILL NOT call DisposeAsync() on the underlying AsyncDisposable
```

The generated `Dispose()` method will call `Dispose()` on the underlying value iff the value is an instance of a type that implements `IDisposable`. The generated `DisposeAsync()` method will call `DisposeAsync()` OR `Dispose()` on the underlying value iff the value is an instance of a type that implements `IAsyncDisposable` or `IDisposable`, respectively.

Be aware that `Dispose()` WILL NOT attempt to call `DisposeAsync()` on an underlying value that is an `IAsyncDisposable` but not an `IDisposable`, so if you are using a union that has both `IDisposable` and `IAsyncDisposable` case types you must ensure that you are calling `DisposeAsync()` on the union, or that all case types implement `IDisposable`. Otherwise your `IAsyncDisposable` cases may not be properly disposed.

The `Dispose()` and `DisposeAsync()` methods on generic unions will use a runtime test to determine if the underlying value implements `IDisposable` or `IAsyncDisposable`. If none of the types implement either of these interfaces, the dispose methods do nothing.

### Struct union types

As mentioned before, `SumSharp` allows for struct and record struct union types. It's important to remember that **any struct union instance that is initialized to `default` is in an invalid state and its behavior is undefined**. The only valid way to create a `SumSharp` union is to use one of its case constructors or conversion operators. C\# allows for any struct instance to be initialized to a `default` value which involves initializing every instance member field to its default value. A `SumSharp` union initialized in such a way is in an invalid, undefined state. Using it may result in exceptions being thrown, or may silently work. **`SumSharp` makes no guarantees about the runtime behavior of default initialized struct unions.**
Expand Down Expand Up @@ -647,7 +744,9 @@ The custom empty type is required to have a parameterless (default) constructor.

### Disabling value equality

All `SumSharp` union types by default implement the `IEquatable<T>` interface, override the `Object.Equals` member function, and implement `==` and `!=` operators. This allows for value type equality between instances: Two instances of the same union type are equal iff they both hold the same case and their underlying values compare equal using the static `Object.Equals` function.
All `SumSharp` union types by default implement the `IEquatable<T>` interface, override the `Object.Equals` member function, and implement `==` and `!=` operators. This allows for value type equality between instances: Two instances of the same union type are equal iff they both hold the same case and their underlying values compare equal using the static `object.Equals` function.

`==` and `!=` comparison operators are also generated for each unique type stored by the union, allowing for direct comparisons between a union and a raw value.

If you'd rather disable this feature and have reference equality for class type unions add the `[DisableValueEquality]` attribute to your union. _Note that adding this attribute does nothing for record union types because the C\# compiler will always add an `IEquatable` implementation for record types._

Expand Down
Loading
Loading