Intro
This small article is about algebraic data types and their surrogates in C#.
Examples are available on my GitHub
What does this term mean?
The term algebraic data type is from the functional paradigm.
In the .NET ecosystem, it is presented by the F# language and called Discriminated unions.
Besides F#, this type is also present in Scala, Haskell, and other functional languages.
Also, many OOP languages adopt DU.
For example, Kotlin has sealed classes, and Java has sealed interfaces.
What about C#? It still does not have its implementation, but the development finally started after several years of moving the proposal to the next year.
Until the new feature is in development .NET community invented several ways to emulate discriminated unions behavior.
What is it about?
If the term discriminated unions is new to you, it would be easy to read the article on Microsoft Learn F# documentation Discriminated Unions.
I would like to explain it in a short description - a type that may be presented by different variants, but only one at a time.
It is like C# enum with fields or one layer of inheritance in C#, but it combines all variants in one place.
Let's start with an example and F# - we need to create a bank account and process a payment for a bank account.
Today, an international bank account might be presented in different systems, for example, IBAN and SWIFT.
type BankAccountCommonData = { Title: string; BankName: string; BankAddress: string }
type BankAccount =
| Iban of common: BankAccountCommonData * Number: string
| Swift of common: BankAccountCommonData * Code: string
// Uncomment this line to get an warring
// | Routing of common: BankAccountCommonData * Routing: string
type BankAccountServiceDu =
static member CreateBankAccount(): BankAccount =
let number = ""
let commonData = { Title = ""; BankName = ""; BankAddress = "" }
Iban(commonData, number)
static member ProcessPayment (payment: string, account: BankAccount) : string =
let ProcessIban (payment: string, accountData: BankAccountCommonData, iban: string) =
// Logic here
String.Empty
let ProcessSwift (payment: string, accountData: BankAccountCommonData, swift: string) =
// Logic here
String.Empty
match account with
| Iban(common, number) -> ProcessIban(payment, common, number)
| Swift(common, code) -> ProcessSwift(payment, common, code)
This F# code shows a Warning - Warning FS0025 : Incomplete pattern matches on this expression, which indicates that not all cases are covered by switch.
That warning can be transformed into an error and block the application from compiling, rather than forcing developers to address an issue with switch.
Why do we need it?
This answer is simple - to present branched logic or data.
The most native way for C# is inheritance. The same example from F# is presented in a traditional C# inheritance:
internal abstract record BankAccountBase
{
public required string Title { get; init; }
public required string BankName { get; init; }
public required string BankAddress { get; init; }
}
internal sealed record IbanBankAccount(string Number) : BankAccountBase;
internal sealed record SwiftBankAccount(string Code) : BankAccountBase;
internal static class BankAccountServiceInheritance
{
internal static BankAccountBase GetBankAccount()
{
// Logic here
return new IbanBankAccount("123")
{
Title = "T",
BankName = "A bank",
BankAddress = "An address"
};
}
internal static void ProcessedPayment(string payment, BankAccountBase bankAccount)
{
string ProcessIban(IbanBankAccount iban, string paymentInfo)
{
// Logic here
return string.Empty;
}
string ProcessSwift(SwiftBankAccount iban, string paymentInfo)
{
// Logic here
return string.Empty;
}
var result = bankAccount switch
{
IbanBankAccount iban => ProcessIban(iban, payment),
SwiftBankAccount swift => ProcessSwift(swift, payment),
_ => throw new ArgumentOutOfRangeException(nameof(bankAccount), typeof(BankAccount).ToString(), null)
};
// Logic here
}
}
"What do we need to improve over there? It looks good!" you may say, but I have to tell you that DU has one necessary part - it is exhaustive switch.
F#, Scala, Haskell, Rust, Java, Kotlin, and even Dart have their implementation of algebraic data types coupled with exhaustive switch.
What does "exhaustive" mean? It is switch that requires covering all possible variants of the variable with a finite set.
Usually in C# code, we reach exhaustiveness with a default arm, which returns a default value or throws an Exception, like in the example above.
In a case, if someone decides to add a new variant of a bank account, for example, with a Routing number for the UK, and forgets to add this variant to the switch in the ProcessedPayment method, the code will compile, and the application will throw ArgumentOutOfRangeException instead of processing a payment with a bank account based on the Routing Number.
With DU and exhaustive switch, we can prevent this issue and avoid a bag.
C# DU
We can get something like an exhaustive switch using out-of-the-box functionality just using a record (class or struct) that combines all branches as nullable properties, and utilize the record's deconstruct method in switch expression.
Look at the ProcessedPaymentExhaustive method below; it does not look pretty, and readability is not great, but it works because the generated deconstruct method includes all properties by default, and the code will not compile if pattern matching does not cover all variables in the tuple.
internal abstract record BankAccountBase
{
public required string Title { get; init; }
public required string BankName { get; init; }
public required string BankAddress { get; init; }
}
internal sealed record IbanBankAccount(string Number) : BankAccountBase;
internal sealed record SwiftBankAccount(string Code) : BankAccountBase;
internal readonly record struct BankCreateResultTuples(BankAccount.Iban? Iban, BankAccount.Swift? Swift);
internal static class BankAccountServiceRecord
{
internal static BankCreateResultTuples GetBankAccount()
{
// Logic here
return new BankCreateResultTuples(
null,
new BankAccount.Swift("123")
{
Title = "T",
BankName = "A bank",
BankAddress = "An address"
}
);
}
/// <summary>
/// Non-exhaustive switch
/// </summary>
internal static void ProcessedPayment(BankCreateResultTuples bankAccount)
{
var result = bankAccount switch
{
{ Iban: null, Swift: not null } => "",
{ Iban: not null, Swift: null } => "",
_ => throw new ArgumentOutOfRangeException(nameof(bankAccount), bankAccount, null)
};
// Logic here
}
/// <summary>
/// Has some exhaustiveness
/// </summary>
internal static void ProcessedPaymentExhaustive(BankCreateResultTuples bankAccount)
{
var result = bankAccount switch
{
(null, not null) => "",
(not null, null) => "",
_ => throw new ArgumentOutOfRangeException(nameof(bankAccount), bankAccount, null)
};
// Logic here
}
}
OneOf
The most well-known surrogate of DU in C# is the OneOf library.
internal readonly record struct BankAccountCommonData(string Title, string BankName, string BankAddress);
/// <summary>
/// A first variant of DU
/// </summary>
internal readonly record struct Iban(string Number, BankAccountCommonData CommonData);
/// <summary>
/// A first variant of DU
/// </summary>
internal readonly record struct Swift(string Code, BankAccountCommonData CommonData);
/// <summary>
/// A possible third variant of DU (not in use)
/// </summary>
internal readonly record struct Routing(string Route, BankAccountCommonData CommonData);
internal static class BankAccountServiceOneOf
{
/// <summary>
/// Return a DU that is presented by <see cref="OneOf{T0, T1}"/>
/// </summary>
internal static OneOf<Iban, Swift> GetBankAccount()
{
// Logic here
var commonData = new BankAccountCommonData("Title", "A bank", "An address");
return new Iban("Number", commonData);
}
/// <summary>
/// Consume a DU that is presented by <see cref="OneOf{T0, T1}"/>
/// Add a third generic to the account argument type to get a compiler error.
/// </summary>
internal static void ProcessPayment(string payment, OneOf<Iban, Swift> account)
{
var result = account.Match(
iban => ProcessIban(iban, payment),
swift => ProcessSwift(swift, payment)
);
string ProcessIban(Iban iban, string paymentInfo)
{
// Logic here
return string.Empty;
}
string ProcessSwift(Swift iban, string paymentInfo)
{
// Logic here
return string.Empty;
}
}
}
It emulates discriminated union behavior using hardcoded generic parameters and overloading types.
It has several modes - we can use a generic type or create our own type and reuse it, but in this case, it should be a class.
OneOf is a wrapper around returning type, it is struct, and allows using class or struct as a generic parameter, which is useful if you are concerned about memory consumption and heap allocations.
To utilize exhaustiveness, we must use the Match method and set for each variant a method for processing this variant.
If someone added a new variant to the method signature, then another overload of OneOf will be used, and the code will not compile until all variants in the Match() method are covered.
The big disadvantages of the library are method signature in asynchronous code - a returning type may be enormous, up to 100 characters, also it requires wrapping all synchronous OneOf returns in Task in the Match method.
Another thing that someone may find inconvenient is that OneOf allows to use of up to 8 variants, but in this case, it would be better to review the logic and entities.
PS: It is similar for Choice type in F# (but this type is a DU under the hood).
StaticCs | static-cs
The library static-cs makes the switch exhaustive!
[Closed]
internal abstract record BankAccount
{
private BankAccount()
{
}
public required string Title { get; init; }
public required string BankName { get; init; }
public required string BankAddress { get; init; }
internal sealed record Iban(string Number) : BankAccount;
internal sealed record Swift(string Code) : BankAccount;
// Uncomment this line to get Error CS8509 during compilation.
// internal sealed record Wire(string Code) : BankAccount;
}
internal static class BankAccountServiceStaticSc
{
internal static BankAccount GetBankAccount()
{
return new BankAccount.Iban("Number")
{
Title = "T",
BankName = "A bank",
BankAddress = "An address"
};
}
internal static void ProcessPayment(string payment, BankAccount account)
{
var result = account switch
{
BankAccount.Iban iban => ProcessIban(iban, payment),
BankAccount.Swift swift => ProcessSwift(swift, payment),
};
string ProcessIban(BankAccount.Iban iban, string paymentInfo)
{
// Logic here
return string.Empty;
}
string ProcessSwift(BankAccount.Swift iban, string paymentInfo)
{
// Logic here
return string.Empty;
}
}
}
It is very easy to use because it uses Roslyn analyzer and brings only one new thing to code - the attribute Closed, anything else is out of the box C# features.
To use a DU and make the switch exhaustive, we should create a base abstract class or record, make the default constructor private, put all derivatives inside the class, and mark it with the attribute Closed.
One necessary detail - do NOT use any default arm in switch anymore!
An additional very useful thing is to make the warning CS8509 as Error to prevent the code from building if not all cases are covered by switch.
So the same example - someone added a new variant of a bank account (with a Routing number for the UK), and forgot to add this variant to the switch in the ProcessedPayment method.
Now the code will NOT compile, the builder shows the error in the console, and a developer is forced to add methods to process all derivatives of the base class.
There are other libraries to emulate DU behavior, but I prefer this way because it has fewer external dependencies and looks more C# native.
PS: Looks familiar?) This construction is very close to Kotlin sealed classes.
sealed class BankAccount {
abstract val title: String
abstract val bankName: String
abstract val bankAddress: String
data class Iban(
val number: String,
override val title: String,
override val bankName: String,
override val bankAddress: String
) : BankAccount()
data class Swift(
val code: String,
override val title: String,
override val bankName: String,
override val bankAddress: String
) : BankAccount()
}
fun createPayment(): BankAccount {
// Logic here
return BankAccount.Iban(number = "Number", title = "Title", bankName = "A bank", bankAddress = "An address")
}
fun processPayment(payment: String, account: BankAccount): String {
fun processIban(iban: BankAccount.Iban, paymentInfo: String): String {
// Logic here
return ""
}
fun processSwift(swift: BankAccount.Swift, paymentInfo: String): String {
// Logic here
return swift.bankName
}
return when (account) {
is BankAccount.Iban -> processIban(account, payment)
is BankAccount.Swift -> processSwift(account, payment)
}
}
Is it an Exception replacement?
Yes and no.
I consider this way to be a much better fit to describe business logic rather than Exception flow because it is explicit and does not depend on a try-catch block, but for libraries or infrastructure-related code, Exceptions might be a better choice.
Just remember, Exception and DU are the only tools, and each tool has its own application area.
Intro
This small article is about algebraic data types and their surrogates in C#.
What does this term mean?
The term algebraic data type is from the functional paradigm.
In the .NET ecosystem, it is presented by the F# language and called Discriminated unions.
Besides F#, this type is also present in Scala, Haskell, and other functional languages.
Also, many OOP languages adopt DU.
For example, Kotlin has sealed classes, and Java has sealed interfaces.
What about C#? It still does not have its implementation, but the development finally started after several years of moving the proposal to the next year.
Until the new feature is in development .NET community invented several ways to emulate discriminated unions behavior.
What is it about?
If the term discriminated unions is new to you, it would be easy to read the article on Microsoft Learn F# documentation Discriminated Unions.
I would like to explain it in a short description - a type that may be presented by different variants, but only one at a time.
It is like C# enum with fields or one layer of inheritance in C#, but it combines all variants in one place.
Let's start with an example and F# - we need to create a bank account and process a payment for a bank account.
Today, an international bank account might be presented in different systems, for example, IBAN and SWIFT.
type BankAccountCommonData = { Title: string; BankName: string; BankAddress: string }
type BankAccount =
| Iban of common: BankAccountCommonData * Number: string
| Swift of common: BankAccountCommonData * Code: string
// Uncomment this line to get an warring
// | Routing of common: BankAccountCommonData * Routing: string
type BankAccountServiceDu =
static member CreateBankAccount(): BankAccount =
let number = ""
let commonData = { Title = ""; BankName = ""; BankAddress = "" }
Iban(commonData, number)
static member ProcessPayment (payment: string, account: BankAccount) : string =
let ProcessIban (payment: string, accountData: BankAccountCommonData, iban: string) =
// Logic here
String.Empty
let ProcessSwift (payment: string, accountData: BankAccountCommonData, swift: string) =
// Logic here
String.Empty
match account with
| Iban(common, number) -> ProcessIban(payment, common, number)
| Swift(common, code) -> ProcessSwift(payment, common, code)
This F# code shows a Warning - Warning FS0025 : Incomplete pattern matches on this expression, which indicates that not all cases are covered by switch.
That warning can be transformed into an error and block the application from compiling, rather than forcing developers to address an issue with switch.
Why do we need it?
This answer is simple - to present branched logic or data.
The most native way for C# is inheritance. The same example from F# is presented in a traditional C# inheritance:
internal abstract record BankAccountBase
{
public required string Title { get; init; }
public required string BankName { get; init; }
public required string BankAddress { get; init; }
}
internal sealed record IbanBankAccount(string Number) : BankAccountBase;
internal sealed record SwiftBankAccount(string Code) : BankAccountBase;
internal static class BankAccountServiceInheritance
{
internal static BankAccountBase GetBankAccount()
{
// Logic here
return new IbanBankAccount("123")
{
Title = "T",
BankName = "A bank",
BankAddress = "An address"
};
}
internal static void ProcessedPayment(string payment, BankAccountBase bankAccount)
{
string ProcessIban(IbanBankAccount iban, string paymentInfo)
{
// Logic here
return string.Empty;
}
string ProcessSwift(SwiftBankAccount iban, string paymentInfo)
{
// Logic here
return string.Empty;
}
var result = bankAccount switch
{
IbanBankAccount iban => ProcessIban(iban, payment),
SwiftBankAccount swift => ProcessSwift(swift, payment),
_ => throw new ArgumentOutOfRangeException(nameof(bankAccount), typeof(BankAccount).ToString(), null)
};
// Logic here
}
}
"What do we need to improve over there? It looks good!" you may say, but I have to tell you that DU has one necessary part - it is exhaustive switch.
F#, Scala, Haskell, Rust, Java, Kotlin, and even Dart have their implementation of algebraic data types coupled with exhaustive switch.
What does "exhaustive" mean? It is switch that requires covering all possible variants of the variable with a finite set.
Usually in C# code, we reach exhaustiveness with a default arm, which returns a default value or throws an Exception, like in the example above.
In a case, if someone decides to add a new variant of a bank account, for example, with a Routing number for the UK, and forgets to add this variant to the switch in the ProcessedPayment method, the code will compile, and the application will throw ArgumentOutOfRangeException instead of processing a payment with a bank account based on the Routing Number.
With DU and exhaustive switch, we can prevent this issue and avoid a bag.
C# DU
We can get something like an exhaustive switch using out-of-the-box functionality just using a record (class or struct) that combines all branches as nullable properties, and utilize the record's deconstruct method in switch expression.
Look at the ProcessedPaymentExhaustive method below; it does not look pretty, and readability is not great, but it works because the generated deconstruct method includes all properties by default, and the code will not compile if pattern matching does not cover all variables in the tuple.
internal abstract record BankAccountBase
{
public required string Title { get; init; }
public required string BankName { get; init; }
public required string BankAddress { get; init; }
}
internal sealed record IbanBankAccount(string Number) : BankAccountBase;
internal sealed record SwiftBankAccount(string Code) : BankAccountBase;
internal readonly record struct BankCreateResultTuples(BankAccount.Iban? Iban, BankAccount.Swift? Swift);
internal static class BankAccountServiceRecord
{
internal static BankCreateResultTuples GetBankAccount()
{
// Logic here
return new BankCreateResultTuples(
null,
new BankAccount.Swift("123")
{
Title = "T",
BankName = "A bank",
BankAddress = "An address"
}
);
}
/// <summary>
/// Non-exhaustive switch
/// </summary>
internal static void ProcessedPayment(BankCreateResultTuples bankAccount)
{
var result = bankAccount switch
{
{ Iban: null, Swift: not null } => "",
{ Iban: not null, Swift: null } => "",
_ => throw new ArgumentOutOfRangeException(nameof(bankAccount), bankAccount, null)
};
// Logic here
}
/// <summary>
/// Has some exhaustiveness
/// </summary>
internal static void ProcessedPaymentExhaustive(BankCreateResultTuples bankAccount)
{
var result = bankAccount switch
{
(null, not null) => "",
(not null, null) => "",
_ => throw new ArgumentOutOfRangeException(nameof(bankAccount), bankAccount, null)
};
// Logic here
}
}
OneOf
The most well-known surrogate of DU in C# is the OneOf library.
internal readonly record struct BankAccountCommonData(string Title, string BankName, string BankAddress);
/// <summary>
/// A first variant of DU
/// </summary>
internal readonly record struct Iban(string Number, BankAccountCommonData CommonData);
/// <summary>
/// A first variant of DU
/// </summary>
internal readonly record struct Swift(string Code, BankAccountCommonData CommonData);
/// <summary>
/// A possible third variant of DU (not in use)
/// </summary>
internal readonly record struct Routing(string Route, BankAccountCommonData CommonData);
internal static class BankAccountServiceOneOf
{
/// <summary>
/// Return a DU that is presented by <see cref="OneOf{T0, T1}"/>
/// </summary>
internal static OneOf<Iban, Swift> GetBankAccount()
{
// Logic here
var commonData = new BankAccountCommonData("Title", "A bank", "An address");
return new Iban("Number", commonData);
}
/// <summary>
/// Consume a DU that is presented by <see cref="OneOf{T0, T1}"/>
/// Add a third generic to the account argument type to get a compiler error.
/// </summary>
internal static void ProcessPayment(string payment, OneOf<Iban, Swift> account)
{
var result = account.Match(
iban => ProcessIban(iban, payment),
swift => ProcessSwift(swift, payment)
);
string ProcessIban(Iban iban, string paymentInfo)
{
// Logic here
return string.Empty;
}
string ProcessSwift(Swift iban, string paymentInfo)
{
// Logic here
return string.Empty;
}
}
}
It emulates discriminated union behavior using hardcoded generic parameters and overloading types.
It has several modes - we can use a generic type or create our own type and reuse it, but in this case, it should be a class.
OneOf is a wrapper around returning type, it is struct, and allows using class or struct as a generic parameter, which is useful if you are concerned about memory consumption and heap allocations.
To utilize exhaustiveness, we must use the Match method and set for each variant a method for processing this variant.
If someone added a new variant to the method signature, then another overload of OneOf will be used, and the code will not compile until all variants in the Match() method are covered.
The big disadvantages of the library are method signature in asynchronous code - a returning type may be enormous, up to 100 characters, also it requires wrapping all synchronous OneOf returns in Task in the Match method.
Another thing that someone may find inconvenient is that OneOf allows to use of up to 8 variants, but in this case, it would be better to review the logic and entities.
PS: It is similar for Choice type in F# (but this type is a DU under the hood).
StaticCs | static-cs
The library static-cs makes the switch exhaustive!
[Closed]
internal abstract record BankAccount
{
private BankAccount()
{
}
public required string Title { get; init; }
public required string BankName { get; init; }
public required string BankAddress { get; init; }
internal sealed record Iban(string Number) : BankAccount;
internal sealed record Swift(string Code) : BankAccount;
// Uncomment this line to get Error CS8509 during compilation.
// internal sealed record Wire(string Code) : BankAccount;
}
internal static class BankAccountServiceStaticSc
{
internal static BankAccount GetBankAccount()
{
return new BankAccount.Iban("Number")
{
Title = "T",
BankName = "A bank",
BankAddress = "An address"
};
}
internal static void ProcessPayment(string payment, BankAccount account)
{
var result = account switch
{
BankAccount.Iban iban => ProcessIban(iban, payment),
BankAccount.Swift swift => ProcessSwift(swift, payment),
};
string ProcessIban(BankAccount.Iban iban, string paymentInfo)
{
// Logic here
return string.Empty;
}
string ProcessSwift(BankAccount.Swift iban, string paymentInfo)
{
// Logic here
return string.Empty;
}
}
}
It is very easy to use because it uses Roslyn analyzer and brings only one new thing to code - the attribute Closed, anything else is out of the box C# features.
To use a DU and make the switch exhaustive, we should create a base abstract class or record, make the default constructor private, put all derivatives inside the class, and mark it with the attribute Closed.
One necessary detail - do NOT use any default arm in switch anymore!
An additional very useful thing is to make the warning CS8509 as Error to prevent the code from building if not all cases are covered by switch.
So the same example - someone added a new variant of a bank account (with a Routing number for the UK), and forgot to add this variant to the switch in the ProcessedPayment method.
Now the code will NOT compile, the builder shows the error in the console, and a developer is forced to add methods to process all derivatives of the base class.
There are other libraries to emulate DU behavior, but I prefer this way because it has fewer external dependencies and looks more C# native.
PS: Looks familiar?) This construction is very close to Kotlin sealed classes.
sealed class BankAccount {
abstract val title: String
abstract val bankName: String
abstract val bankAddress: String
data class Iban(
val number: String,
override val title: String,
override val bankName: String,
override val bankAddress: String
) : BankAccount()
data class Swift(
val code: String,
override val title: String,
override val bankName: String,
override val bankAddress: String
) : BankAccount()
}
fun createPayment(): BankAccount {
// Logic here
return BankAccount.Iban(number = "Number", title = "Title", bankName = "A bank", bankAddress = "An address")
}
fun processPayment(payment: String, account: BankAccount): String {
fun processIban(iban: BankAccount.Iban, paymentInfo: String): String {
// Logic here
return ""
}
fun processSwift(swift: BankAccount.Swift, paymentInfo: String): String {
// Logic here
return swift.bankName
}
return when (account) {
is BankAccount.Iban -> processIban(account, payment)
is BankAccount.Swift -> processSwift(account, payment)
}
}
Is it an Exception replacement?
Yes and no.
I consider this way to be a much better fit to describe business logic rather than Exception flow because it is explicit and does not depend on a try-catch block, but for libraries or infrastructure-related code, Exceptions might be a better choice.
Just remember, Exception and DU are the only tools, and each tool has its own application area.
Top comments (0)