Discriminated Unions - PawelGerr/Thinktecture.Runtime.Extensions GitHub Wiki
Discriminated unions are a powerful feature that allows a type to hold a value that could be one of several different types. They bring type safety and exhaustive pattern matching to these scenarios — the compiler ensures every case is handled, eliminating an entire class of runtime errors.
- Articles
- Why Discriminated Unions?
- Getting Started
- Ad hoc unions
- Regular unions
- Pattern Matching with Switch/Map
- Further Topics
- Real-world use cases and ideas
This guide covers the basics. For advanced topics, see the companion pages:
- Discriminated Unions: Representation of Alternative Types in .NET
- Pattern Matching with Discriminated Unions in .NET
- Discriminated Unions in .NET: Modeling States and Variants
- Discriminated Unions in .NET: Integration with Frameworks and Libraries
Traditional C# approaches for representing values with multiple possible outcomes often lead to runtime errors and code that's difficult to maintain. Here are three common patterns and their pitfalls:
Tuples with nullable fields — A method that loads an order from the database returns (Order? Order, bool IsSoftDeleted, string? Error) to represent three possible outcomes: the order was found, it was soft-deleted, or an error occurred. The type system doesn't prevent invalid combinations (e.g., order is null, error is null, and isSoftDeleted is false). Correct interpretation relies on conventions, not the compiler.
Result<T> with boolean flags — A generic Result<T> class with IsSuccess, Value, and Error properties. The compiler doesn't prevent accessing Value when IsSuccess is false, leading to potential NullReferenceException. There's no exhaustiveness guarantee — adding new states to the status flag doesn't produce compile errors at call sites.
Exceptions for control flow — Using exceptions for expected outcomes like "not found" creates performance overhead, obscures normal flow, and requires the caller to know which exceptions a method might throw (unlike return types, exceptions aren't part of the method signature).
Discriminated unions solve these problems by modeling the alternatives directly in the type system. A Result<T> refactored as a union can only be Success or Failure — never both, never neither. The library generates a Switch method that requires a handler for every case — forget one, and the code won't compile:
[Union]
public partial record Result<T>
{
public sealed record Success(T Value) : Result<T>;
public sealed record Failure(string Error) : Result<T>;
}
Result<Order> result = GetOrder(123);
result.Switch(
success: s => Handle(s.Value),
failure: f => Handle(f.Error) // Compile error if you forget this
);Identifying Discriminated Union Candidates in Your Code:
- Classes with "type flags" or enums used to determine how to interpret other properties
- Extensive
switch/if-elsechains based on an object's runtime type or status flag - Methods returning complex tuples with multiple nullable properties representing mutually exclusive outcomes
- Base classes where derived classes mainly differ by the data they hold for a specific state
The library provides two flavors of unions:
-
Ad hoc unions: Combine types that share no common base class into a single union type. Use these when the member types (generics) are unrelated and already exist independently (e.g.,
string,int,List<ValidationError>). - Regular unions: Model a type hierarchy where all cases derive from a common abstract base. Use these when the cases are conceptually related and benefit from shared behavior or properties.
Requirements (Ad hoc Unions)
- The type must be declared as
partial- Apply
[Union<T1, T2>]or[AdHocUnion(typeof(T1), typeof(T2))](when generic approach is not possible)- The union type can be generic — use
TypeParamRef1–TypeParamRef5to reference the union's own type parameters (see Generic ad hoc unions)
Requirements (Regular Unions)
- Base class must be
partial- Apply
[Union]attribute to the base type- Derived types must be nested inside the base type and be
sealedor have a private constructor
Ad hoc unions combine types that share no common base class into a single union type — the simplest way to get started.
Create a partial class, struct or ref struct and annotate it with either the generic UnionAttribute<T1, T2> or the non-generic AdHocUnionAttribute.
// class
[Union<string, int>]
public partial class TextOrNumber;
// struct
[Union<string, int>]
public partial struct TextOrNumber;
// ref struct
[Union<string, int>]
public ref partial struct TextOrNumber;The generic UnionAttribute allows up to 5 member types:
[Union<string, int, bool, Guid, char>]
public partial class MyUnion;The non-generic AdHocUnionAttribute is useful when you need to specify types that cannot be expressed as generic type parameters, such as nullable reference types (List<string?>), or other edge cases where C# generic attribute constraints become limiting.
// Using AdHocUnion with typeof()
[AdHocUnion(typeof(string), typeof(int))]
public partial class TextOrNumber;
// Up to 5 types supported
[AdHocUnion(typeof(string), typeof(int), typeof(bool), typeof(Guid), typeof(char))]
public partial class MyUnion;
// Complex scenarios: nullable reference types in generic collections
[AdHocUnion(typeof(List<string?>), typeof(int))]
public partial class ListOrNumber;Both approaches generate identical functionality. Use the generic approach for simplicity, and the non-generic approach when you hit generic attribute limitations.
The source generator creates a comprehensive set of features:
// Implicit conversion from one of the member types
TextOrNumber textOrNumberFromString = "text";
TextOrNumber textOrNumberFromInt = 42;
// Type checking
bool isText = textOrNumberFromString.IsString;
bool isNumber = textOrNumberFromString.IsInt32;
// Type-safe value access
// Throws "InvalidOperationException" if type doesn't match
string text = textOrNumberFromString.AsString;
int number = textOrNumberFromInt.AsInt32;
// Alternative: explicit casting
string text = (string)textOrNumberFromString;
int number = (int)textOrNumberFromInt;
// Get untyped value
object value = textOrNumberFromString.Value;
// Built-in equality comparison
bool equals = textOrNumberFromInt.Equals(textOrNumberFromString);
bool equal = textOrNumberFromInt == textOrNumberFromString;
bool notEqual = textOrNumberFromInt != textOrNumberFromString;
// ToString - delegates to the contained value's ToString()
string str = textOrNumberFromInt.ToString(); // "42"
// GetHashCode - delegates to the contained value's GetHashCode()
int hash = textOrNumberFromInt.GetHashCode();Note: C# does not allow user-defined conversion operators from
objector interface types. If a member type isobjector an interface, no implicit or explicit conversion operator is generated for that type — use the constructor instead.
Serialization and model binding: Ad hoc unions don't carry a type discriminator, so they can't be serialized as polymorphic JSON out of the box. To enable JSON serialization, ASP.NET Core model binding, or EF Core persistence, add
[ObjectFactory<T>]to define how the union converts to/from a primitive type. See Framework Integration for a worked example.
Ad hoc unions can be generic. Use the placeholder types TypeParamRef1 through TypeParamRef5 (from the Thinktecture namespace) to reference the union type's own type parameters. TypeParamRefN maps to the Nth type parameter (1-based).
Each TypeParamRef placeholder becomes the corresponding type parameter in the generated code:
using Thinktecture;
[Union<TypeParamRef1, TypeParamRef2>]
public partial struct Either<T1, T2>;
// Usage — type parameter members don't get implicit conversion operators,
// use constructors or factory methods instead
Either<string, int> value = new Either<string, int>("hello"); // constructor
Either<string, int> number = Either<string, int>.CreateT2(42); // factory methodYou can combine TypeParamRef placeholders with concrete types in the same union:
using Thinktecture;
[Union<TypeParamRef1, string>]
public partial struct Result<T>;
// Usage
Result<int> success = Result<int>.CreateT(42); // factory method — T is a type parameter
Result<int> error = "Something failed"; // implicit conversion — string is a concrete typeThe non-generic AdHocUnionAttribute works the same way:
using Thinktecture;
[AdHocUnion(typeof(TypeParamRef1), typeof(string))]
public partial struct Result<T>;TypeParamRef placeholders can appear inside constructed generic types. The source generator resolves them to the actual type parameter:
using Thinktecture;
[Union<TypeParamRef1, List<TypeParamRef1>>]
public partial struct SingleOrMany<T>;
// Usage
SingleOrMany<int> single = SingleOrMany<int>.CreateT(42); // factory method — T is a type parameter
SingleOrMany<int> many = new List<int> { 1, 2, 3 }; // implicit conversion — List<int> is a concrete constructed typeFor union members that are type parameters, the source generator produces constructors and factory methods instead of conversion operators. C# does not allow user-defined implicit or explicit conversion operators involving type parameters.
When any member triggers factory method generation (type parameter, interface, System.Object, or duplicate type), factory methods are generated for all members — not just the triggering ones. Constructors and conversion operators are still generated for eligible members as usual.
[Union<TypeParamRef1, string>]
public partial struct Result<T>;
// Type parameter member — use constructor or factory method (no implicit conversion available)
Result<int> success = new Result<int>(42);
Result<int> alsoSuccess = Result<int>.CreateT(42);
// Concrete type member — implicit conversion still works, factory method also available
Result<int> error = "Something failed";
Result<int> alsoError = Result<int>.CreateString("Something failed");You can control factory method generation with the FactoryMethodGeneration property. See the Customization guide for details.
The generated IsT/AsT properties, Switch/Map methods, and equality members all work with type parameter members the same way they do with concrete types.
While ad hoc unions are quick to set up for combining existing types, regular unions use inheritance to model domain concepts where each case can have its own properties, validation, and behavior.
Regular unions provide more flexibility through inheritance. They're ideal for:
- Domain modeling with distinct subtypes
- Complex hierarchies with shared behavior
- Integration with other features like value objects
Simple union using inheritance:
[Union]
public partial class Animal
{
public sealed class Dog : Animal
{
public string Name { get; }
public Dog(string name) => Name = name;
}
public sealed class Cat : Animal
{
public string Name { get; }
public Cat(string name) => Name = name;
}
}
// Usage
Animal animal = new Animal.Dog("Rover");
// Pattern matching
animal.Switch(
dog: d => Console.WriteLine($"Dog: {d.Name}"),
cat: c => Console.WriteLine($"Cat: {c.Name}")
);Same as above but using records:
[Union]
public partial record AnimalRecord
{
public sealed record Dog(string Name) : AnimalRecord;
public sealed record Cat(string Name) : AnimalRecord;
}The source generator implements an implicit conversion for every unique constructor parameter type. The generation of the conversion can be customized the same way as with ad hoc unions (see Customizing conversion operators).
Regular unions also support nesting — a derived type can itself be a [Union] with further sealed subtypes, enabling hierarchical modeling like ApiResponse > Failure > NotFound | Unauthorized. See Nesting Unions in the Customization guide for the full rules.
Regular unions can encapsulate state-specific behavior using abstract methods on the base class, with each derived type providing its own implementation:
[Union]
public abstract partial record OrderState
{
// Behavior specific to the state
public abstract bool CanCancel();
public sealed record Pending(DateTime CreatedAt) : OrderState
{
public override bool CanCancel() => true; // Can cancel while pending
}
public sealed record Shipped(DateTime ShippedAt, string TrackingNumber) : OrderState
{
public override bool CanCancel() => false; // Cannot cancel once shipped
}
// more states ...
}
// Usage
OrderState currentState = /* get state */;
if (currentState.CanCancel())
{
// Proceed with cancellation
}Alternatively, behavior can be implemented externally using the Switch method, which is often preferred for state transitions or operations involving multiple domain objects:
public bool TryCancelOrder(Order order)
{
return order.CurrentState.Switch(
pending: _ =>
{
order.SetState(new OrderState.Cancelled(DateTime.UtcNow, "Cancelled by user"));
return true;
},
shipped: _ => false, // Cannot cancel
// handling of other states
);
}When to use each approach:
-
Abstract methods: Use when the behavior is inherent to the state itself and requires no external dependencies (e.g.,
CanCancel(),GetDisplayName()). - External Switch: Prefer when the operation involves state transitions, interacts with multiple domain objects, or has cross-cutting concerns like logging or authorization.
Serialization and persistence: Regular unions are polymorphic types. For JSON, apply
[JsonDerivedType]attributes to the base class so System.Text.Json includes a$typediscriminator. For EF Core, use the standard TPH or TPT inheritance mapping. See Framework Integration for configuration details (including a complete EF Core example) and the Partially Known Date use case below.
C#'s native switch expression doesn't enforce exhaustive handling of all union cases. Without a default case, the compiler may warn that not all paths return a value — but adding _ => ... to silence the warning hides bugs when new types are added later:
// Native switch — no compile-time safety when new cases are added
return textOrNumber switch
{
string s => $"Text: {s}",
int i => $"Number: {i}",
_ => "Unknown" // Hides unhandled cases — if a new type is added, this silently catches it
};The generated Switch and Map methods solve this. They require a handler for every case in the union, so adding a new type produces a compile-time error at every call site that hasn't been updated.
Key Advantages:
- Compile-time exhaustiveness: Forgetting a case is a compile error, not a runtime bug
- Type safety: Each handler receives the correctly typed value — no casting needed
- Readability: Named parameters clearly express which case is being handled
All Switch/Map methods are exhaustive by default, ensuring all cases are handled correctly.
Both Switch and Map come in multiple overloads:
-
Switch(void) accepts anActionper member — use it for side effects with no return value. -
Switch(with return value) accepts aFunc<TResult>per member — use it to compute and return a result. -
Mapaccepts a value ofTResultper member directly (no lambdas) — a concise shorthand when each member maps to a constant.
IDE Tip: Place the cursor on a
Switch,Map,SwitchPartially, orMapPartiallycall and use the light bulb (Quick Actions) to auto-generate all missing arguments with the correct lambda signatures.
Each of the above also has an overload that accepts an additional state parameter, allowing the use of static lambdas to avoid closure allocations (see Performance: Avoiding Closures below).
Ad hoc union example:
TextOrNumber union = "Hello, World!";
// With "Action"
union.Switch(@string: s => logger.LogInformation("[Switch] String Action: {Text}", s),
int32: i => logger.LogInformation("[Switch] Int Action: {Number}", i));
// With "Action". Logger is passed as additional parameter to prevent closures.
union.Switch(logger,
@string: static (l, s) => l.LogInformation("[Switch] String Action with logger: {Text}", s),
int32: static (l, i) => l.LogInformation("[Switch] Int Action with logger: {Number}", i));
// With "Func<T>"
var switchResponse = union.Switch(@string: static s => $"[Switch] String Func: {s}",
int32: static i => $"[Switch] Int Func: {i}");
// With "Func<T>" and additional argument to prevent closures.
var switchResponseWithContext = union.Switch(123.45,
@string: static (value, s) => $"[Switch] String Func with value: {value} | {s}",
int32: static (value, i) => $"[Switch] Int Func with value: {value} | {i}");Use Map instead of Switch to return concrete values directly.
var mapResponse = union.Map(@string: "[Map] Mapped string",
int32: "[Map] Mapped int");Regular union example:
Animal animal = new Animal.Dog("Rover");
// With "Action"
animal.Switch(
dog: d => Console.WriteLine($"Dog: {d.Name}"),
cat: c => Console.WriteLine($"Cat: {c.Name}"));
// With "Func<T>"
var description = animal.Switch(
dog: d => $"Dog: {d.Name}",
cat: c => $"Cat: {c.Name}");
// With "Map"
var label = animal.Map(dog: "Dog", cat: "Cat");The Switch and Map methods have overloads that accept an additional state/context parameter, allowing you to use static lambdas and avoid closure allocations. This is beneficial in performance-sensitive code paths.
ILogger logger = /* ILogger instance */;
opResult.Switch(
logger, // Pass logger as state
success: static (log, s) => log.LogInformation("Success: {Value}", s.Value), // Static lambda
failure: static (log, f) => log.LogError("Failure: {Error}", f.Error) // Static lambda
);When using Switch with a return value, all lambdas must return the same type TResult. If they return different-but-compatible types, the compiler cannot infer TResult and marks the entire Switch call as an error — making it hard to spot which lambda is the problem.
For example, this will not compile because List<int> and int[] are different types, even though both are assignable to IReadOnlyList<int>:
// ERROR: The entire Switch call is flagged red
IReadOnlyList<int> result = union.Switch(
@string: static s => new List<int> { s.Length },
int32: static i => new int[] { i }
);Fix: Specify the generic type parameter explicitly. This way, only the lambda with the incompatible return type will be flagged:
// OK: Explicit TResult tells the compiler what to expect
IReadOnlyList<int> result = union.Switch<IReadOnlyList<int>>(
@string: static s => new List<int> { s.Length },
int32: static i => new int[] { i }
);Non-exhaustive overloads for regular unions: For nested union hierarchies, you can generate
SwitchPartially/MapPartiallyoverloads that handle intermediate union types as a single case using[UnionSwitchMapOverload]. See Customization for details.
- Customization — Property naming, conversion operators, constructors, nullable types, memory optimization, partial matching, equality, nested parameter names, nesting unions
- Framework Integration — JSON serialization, ASP.NET Core model binding, Entity Framework Core
-
Object Factories — Custom serialization, zero-allocation JSON,
ObjectFactoryAttribute<T>configuration - Source Generator Configuration — Logging, JetBrains annotations
Discriminated unions are particularly valuable in Domain-Driven Design (DDD) for modeling alternatives within a domain. They enforce invariants at the type level, express ubiquitous language directly in code, and make the different states or variations of a concept explicit. Each case can carry its own specific data and validation rules, preventing invalid states by construction rather than by runtime checks.
Here are some examples I used in the past to show the developers the benefits of discriminated unions.
Ad hoc unions are well-suited for API response handling where the response types already exist and don't share a common class hierarchy:
// Existing response types
public record ProductDto(int Id, string Name, decimal Price);
public record ApiError(int StatusCode, string Message);
public record ValidationFailure(string Field, string Message);
// Ad hoc union combining the possible response types
[Union<ProductDto, ApiError, List<ValidationFailure>>]
public partial class GetProductResponse;A Web API controller can use Switch to transform the union into an IActionResult:
public IActionResult HandleApiResponse(int productId)
{
GetProductResponse response = GetProductById(productId);
// Use Switch to convert the union into an IActionResult
return response.Switch<IActionResult>(
productDto: product => Ok(product), // HTTP 200 OK with product data
apiError: error => StatusCode(error.StatusCode, error.Message), // HTTP 404 or 500
listOfValidationFailure: errors => BadRequest(errors) // HTTP 400 Bad Request
);
}Regular unions model order lifecycle states where each state carries specific data. The Switch method ensures all states are handled at compile time:
[Union]
public abstract partial record OrderState
{
public sealed record New(string CreatedBy) : OrderState;
public sealed record Processing(DateTime StartedAt, string ProcessedBy) : OrderState;
public sealed record Shipped(DateTime ShippedAt, string TrackingNumber, string Carrier) : OrderState;
public sealed record Cancelled(DateTime CancelledAt, string Reason, decimal? CancellationFee) : OrderState;
}The Order class manages state transitions using Switch:
public record UserPermissions(bool CanShipOrder);
public class Order
{
private readonly List<OrderState> _states;
public int Id { get; }
public OrderState CurrentState => _states[^1]; // Get the last state in the list
public Order(int id, string createdBy)
{
Id = id;
_states = [new OrderState.New(createdBy)]; // Initial state
}
// Method for state transition
public bool Ship(string trackingNumber, string carrier, UserPermissions userPermissions)
{
// Use Switch to determine the outcome
return CurrentState.Switch(
processing: _ =>
{
// Check permission
if (!userPermissions.CanShipOrder)
return false; // User does not have permission to ship this order
// Change state
_states.Add(new OrderState.Shipped(DateTime.UtcNow, trackingNumber, carrier));
return true;
},
@new: static _ => false, // Order must be processed before shipping
shipped: static _ => false, // Order has already been shipped
cancelled: static _ => false); // Cannot ship a cancelled order
}
// Gets summary string
public string GetStatusSummary()
{
return CurrentState.Switch(
@new: static state => $"Order created by {state.CreatedBy}.",
processing: static state => $"Order processing since {state.StartedAt}.",
shipped: static state => $"Order shipped on {state.ShippedAt} via {state.Carrier}.",
cancelled: static state => $"Order cancelled on {state.CancelledAt}: {state.Reason}"
);
}
}Different message types (email, SMS, etc.) need different processing logic but should return a uniform result. Here, each MessageProcessorType item holds its own processing delegate (see Smart Enums — Custom methods), and ProcessingResult is a union of Success and Failure.
[Union]
public abstract partial record ProcessingResult
{
public sealed record Success(string TransactionId, DateTime Timestamp) : ProcessingResult;
public sealed record Failure(string ErrorMessage) : ProcessingResult;
}
[SmartEnum<string>]
public partial class MessageProcessorType
{
public static readonly MessageProcessorType Email = new("EMAIL", ProcessEmail);
public static readonly MessageProcessorType Sms = new("SMS", ProcessSms);
[UseDelegateFromConstructor]
public partial ProcessingResult Process(string message); // Returns a discriminated union
// Dummy processing logic
private static ProcessingResult ProcessEmail(string message)
{
if (ContainsInvalidCharacter(message))
return "Email processing failed."; // Failure
return new ProcessingResult.Success("123", DateTime.UtcNow); // Success
}
private static ProcessingResult ProcessSms(string message)
{
return new ProcessingResult.Success("123", DateTime.UtcNow); // Success
}
}Notice that ProcessEmail returns a string for the failure case — the implicit conversion operator (generated by the union) converts it to ProcessingResult.Failure automatically. Each processor item is self-contained: adding a new processor type (e.g., PushNotification) is a single field declaration with its own delegate, and the Switch on the result ensures all callers handle both outcomes.
Usage:
var processorType = MessageProcessorType.Email;
ProcessingResult result = processorType.Process("Your order shipped!");
// Handle result — compile-time exhaustive
result.Switch(
success: success => Handle(success.TransactionId, success.Timestamp),
failure: failure => Handle(failure.ErrorMessage)
);Some use-cases need to handle dates with varying levels of precision.
For example, historical dates might only be known by year ("born in 1980") or by year and month ("occurred in June 1995").
The PartiallyKnownDate discriminated union provides an elegant solution to this problem.
[Union]
public abstract partial record PartiallyKnownDate
{
public int Year { get; }
private PartiallyKnownDate(int year)
{
Year = year;
}
public sealed record YearOnly(int Year) : PartiallyKnownDate(Year);
public sealed record YearMonth(int Year, int Month) : PartiallyKnownDate(Year);
public sealed record Date(int Year, int Month, int Day) : PartiallyKnownDate(Year);
public static implicit operator PartiallyKnownDate(DateOnly dateOnly) =>
new Date(dateOnly.Year, dateOnly.Month, dateOnly.Day);
public static implicit operator PartiallyKnownDate?(DateOnly? dateOnly) =>
dateOnly is null ? null : dateOnly.Value;
}Usage:
// Date with only year known
var historicalEvent = new PartiallyKnownDate.YearOnly(2000);
// Date with year and month known
var approximateBirthdate = new PartiallyKnownDate.YearMonth(1980, 6);
// Fully known date
var preciseDate = new PartiallyKnownDate.Date(2024, 12, 31);
// Implicit conversion from DateOnly to PartiallyKnownDate
PartiallyKnownDate fullDate = new DateOnly(2024, 3, 15); // Date(2024, 3, 15)
static string FormatDate(PartiallyKnownDate date)
{
return date.Switch(
yearOnly: y => y.Year.ToString(),
yearMonth: ym => $"{ym.Year}-{ym.Month:D2}",
date: ymd => $"{ymd.Year}-{ymd.Month:D2}-{ymd.Day:D2}"
);
}
Console.WriteLine($"Historical event: {FormatDate(historicalEvent)}"); // "2000"
Console.WriteLine($"Approximate birthdate: {FormatDate(approximateBirthdate)}"); // "1980-06"
Console.WriteLine($"Precise date: {FormatDate(preciseDate)}"); // "2024-12-31"This example demonstrates combining discriminated unions with value objects and smart enums — the union represents the type of jurisdiction, value objects represent the value and rules of each type, and a smart enum models a fixed set of known continents. Jurisdictions can be a country, a federal state, a district, or a continent. Each case enforces its own invariants (ISO code length, non-empty district name), and the "Unknown" state is modeled explicitly within the union rather than relying on null.
[Union]
public abstract partial class Jurisdiction
{
[ValueObject<string>(KeyMemberName = "IsoCode")]
[KeyMemberEqualityComparer<ComparerAccessors.StringOrdinalIgnoreCase, string>] // case-insensitive comparison
[KeyMemberComparer<ComparerAccessors.StringOrdinalIgnoreCase, string>]
public partial class Country : Jurisdiction
{
static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref string isoCode)
{
if (string.IsNullOrWhiteSpace(isoCode))
{
validationError = new ValidationError("ISO code is required.");
isoCode = string.Empty;
return;
}
isoCode = isoCode.Trim();
if (isoCode.Length != 2)
validationError = new ValidationError("ISO code must be exactly 2 characters long.");
}
}
/// <summary>
/// Let's assume that the federal state is represented by an number.
/// </summary>
[ValueObject<int>(KeyMemberName = "Number")]
public partial class FederalState : Jurisdiction;
[ValueObject<string>(KeyMemberName = "Name")]
[KeyMemberEqualityComparer<ComparerAccessors.StringOrdinalIgnoreCase, string>] // case-insensitive comparison
[KeyMemberComparer<ComparerAccessors.StringOrdinalIgnoreCase, string>]
public partial class District : Jurisdiction;
/// <summary>
/// The complex type adds appropriate equality comparison(i.e. it checks for type only).
/// </summary>
[ComplexValueObject(SkipFactoryMethods = true)]
public partial class Unknown : Jurisdiction
{
public static readonly Unknown Instance = new();
}
/// <summary>
/// Continent as a Smart Enum — demonstrates combining Smart Enums with unions.
/// </summary>
[SmartEnum<string>]
public partial class Continent : Jurisdiction
{
public static readonly Continent Africa = new("Africa");
public static readonly Continent Antarctica = new("Antarctica");
public static readonly Continent Asia = new("Asia");
public static readonly Continent Australia = new("Australia");
public static readonly Continent Europe = new("Europe");
public static readonly Continent NorthAmerica = new("North America");
public static readonly Continent SouthAmerica = new("South America");
}
}Usage:
// Creating different jurisdictions
var district = Jurisdiction.District.Create("District 42");
var country = Jurisdiction.Country.Create("DE");
var europe = Jurisdiction.Continent.Europe;
var unknown = Jurisdiction.Unknown.Instance;
// Comparing jurisdictions
var district42 = Jurisdiction.District.Create("DISTRICT 42");
Console.WriteLine($"district == district42: {district == district42}"); // true
var district43 = Jurisdiction.District.Create("District 43");
Console.WriteLine($"district == district43: {district == district43}"); // false
Console.WriteLine($"unknown == Jurisdiction.Unknown.Instance: {unknown == Jurisdiction.Unknown.Instance}"); // true
// Validation examples
try
{
var invalidJuristiction = Jurisdiction.Country.Create("DEU"); // Throws ValidationException
}
catch (ValidationException ex)
{
Console.WriteLine(ex.Message); // "ISO code must be exactly 2 characters long."
}
var description = district.Switch(
country: c => $"Country: {c}",
federalState: s => $"Federal state: {s}",
district: d => $"District: {d}",
continent: c => $"Continent: {c}",
unknown: _ => "Unknown"
);
Console.WriteLine(description);If the Jurisdiction must be serialized as JSON then it requires a custom JSON converter.
The converter needs to know the type of the object to be serialized. This can be achieved by using a Smart Enum as a discriminator (JurisdictionJsonConverter).