-
Notifications
You must be signed in to change notification settings - Fork 5
Discriminated Unions Customization
Discriminated unions are highly customizable. This page covers all customization options for both ad hoc and regular unions.
-
Ad Hoc Union Customization
- Renaming of properties
- Default string comparison
- Skip implementation of ToString
- Constructor access modifier
- Customizing conversion operators
- Definition of nullable reference types
- Single backing field
- Stateless types for memory optimization
- Pattern matching with partial coverage
- Factory method generation
- Skipping equality comparison
- Regular Union Customization
- Setting Dependencies
Both the generic UnionAttribute<T1, T2> and non-generic AdHocUnionAttribute support the same customization options. These options also apply to generic ad hoc unions that use TypeParamRef placeholders.
Use T1Name/T2Name/../T5Name to create more meaningful property names:
// Generic approach
[Union<string, int>(T1Name = "Text", T2Name = "Number")]
public partial class TextOrNumber;
// Non-generic approach
[AdHocUnion(typeof(string), typeof(int), T1Name = "Text", T2Name = "Number")]
public partial class TextOrNumber;
// Properties use the custom names
bool isText = union.IsText;
bool isNumber = union.IsNumber;
string text = union.AsText;
int number = union.AsNumber;Configure string comparison behavior:
[Union<string, int>(DefaultStringComparison = StringComparison.Ordinal)]
public partial class TextOrNumber;Disable ToString generation:
[Union<string, int>(SkipToString = true)]
public partial class TextOrNumber;Control constructor accessibility:
This feature is useful in conjunction with
ConversionFromValue = ConversionOperatorsGeneration.Nonefor creation of unions with additional properties. See the TextOrNumberExtended example below.
[Union<string, int>(ConstructorAccessModifier = UnionConstructorAccessModifier.Private)]
public partial class TextOrNumber;Available values for UnionConstructorAccessModifier:
-
Private-- constructor is private (useful when providing custom factory methods) -
Internal-- constructor is internal (accessible within the same assembly) -
Public-- constructor is public (the default)
The generation of conversion operators (implicit/explicit casts) between member types and the union type can be customized using ConversionFromValue and ConversionToValue.
ConversionFromValue controls conversions FROM member values TO the union type. Default is ConversionOperatorsGeneration.Implicit.
ConversionToValue controls conversions FROM the union type TO the member value type. Default is ConversionOperatorsGeneration.Explicit.
The ConversionOperatorsGeneration enum has the following values:
-
None-- no conversion operators are generated -
Implicit-- implicit cast operators are generated -
Explicit-- explicit cast operators are generated
Note: C# does not allow user-defined conversion operators from
objector interface types. If a member type isobjector an interface, no conversion operator is generated regardless of these settings — use the constructor instead.
Disable all inbound conversions:
[Union<string, int>(
ConversionFromValue = ConversionOperatorsGeneration.None,
ConstructorAccessModifier = UnionConstructorAccessModifier.Private)]
public partial class TextOrNumberExtended
{
public required string AdditionalProperty { get; init; }
public TextOrNumberExtended(string text, string additionalProperty)
: this(text)
{
AdditionalProperty = additionalProperty;
}
public TextOrNumberExtended(int number, string additionalProperty)
: this(number)
{
AdditionalProperty = additionalProperty;
}
}Enable nullable reference types with T1IsNullableReferenceType:
// Generic approach
[Union<string, int>(T1IsNullableReferenceType = true)]
public partial class TextOrNumber;
// Non-generic approach
[AdHocUnion(typeof(string), typeof(int), T1IsNullableReferenceType = true)]
public partial class TextOrNumber;
// Now string is treated as nullable
TextOrNumber union = (string?)null;
string? text = union.AsString; // Type is string?The generator automatically optimizes how backing fields are laid out depending on the member types:
Separate fields (default): When a union has only one reference type (or none), each member type gets its own strongly-typed backing field. No casting is needed at runtime.
[Union<string, int>]
public partial class TextOrNumber;
// Generated fields: string _string; int _int32;Shared reference field (automatic): When a union has two or more reference types, the generator automatically combines them into a single object? field. Value types still keep their own typed fields, so no boxing occurs.
[Union<string, int, Exception>]
public partial class MyUnion;
// Generated fields: object? _obj; int _int32;
// string and Exception share _obj; int keeps its own fieldUseSingleBackingField = true: Forces all types — including value types — into a single object? field. This minimizes field count but causes boxing for value types.
[Union<string, int, bool>(UseSingleBackingField = true)]
public partial class MyUnion;
// Generated fields: object? _obj;Note: Because reference types are already merged automatically when there are two or more,
UseSingleBackingFieldmainly affects value types — it forces them into the shared field as well, at the cost of boxing.
Stateless types are union member types that represent states or sentinels without carrying meaningful instance data. When a type is marked as stateless, the union stores only the discriminator index rather than allocating storage for the instance itself.
Memory benefits:
- No backing field allocated for stateless type members
- Reduced memory footprint, especially beneficial for unions with multiple "state" types
- Lower GC pressure due to fewer heap allocations
Usage:
// Generic approach
[Union<EmptyState, string, int>(T1IsStateless = true)]
public partial struct DataOrEmpty;
public readonly record struct EmptyState; // Carries no data, just represents "empty" state
// Non-generic approach
[AdHocUnion(typeof(EmptyState), typeof(string), T1IsStateless = true)]
public partial struct DataOrEmpty;
// Usage
DataOrEmpty empty = new EmptyState(); // Memory efficient: only stores index
DataOrEmpty withData = "hello";
// Accessors return default(T) for stateless types
EmptyState emptyInstance = empty.AsEmptyState; // Returns default(EmptyState)Real-world example: API Response with stateless states
[Union<SuccessResponse, NotFoundError, UnauthorizedError>(
T1Name = "Success",
T2Name = "NotFound", T2IsStateless = true,
T3Name = "Unauthorized", T3IsStateless = true)]
public partial class ApiResponse
{
public class SuccessResponse
{
public required string Data { get; init; }
}
public readonly record struct NotFoundError; // Stateless: presence of this type IS the information
public readonly record struct UnauthorizedError; // Stateless: presence of this type IS the information
}
// Memory efficient: NotFound and Unauthorized don't allocate backing fields
ApiResponse response = new ApiResponse.NotFoundError();
response.Switch(
success: s => Console.WriteLine($"Data: {s.Data}"),
notFound: _ => Console.WriteLine("Resource not found"),
unauthorized: _ => Console.WriteLine("Not authorized")
);When to use stateless types:
- Empty state structs or classes that represent "none," "empty," "unknown," etc.
- Error types that need no additional information beyond their presence
- State machine states that carry no payload
- Sentinel types in unions where the type discriminator itself conveys all necessary information
Why prefer structs for stateless types:
While reference types can also be marked as stateless, using readonly struct types is strongly recommended for stateless members. The key advantage is usability, not memory:
-
Avoid null-handling complexity:
default(MyStruct)is a valid value, whereasdefault(MyClass)isnull, requiring null checks - Simpler code: No need to handle nullable reference types or worry about null dereference
Important notes:
- Stateless types automatically set
TXIsNullableReferenceType = truefor reference types (sincedefault(T)for reference types isnull) - Accessors like
AsT1andValuereturndefault(T)for stateless types - Equality comparison for stateless types is based solely on discriminator matching
- Structs used as stateless types are typically empty
readonly structtypes
Enable partial matching with SwitchPartially and MapPartially:
[Union<string, int>(
SwitchMethods = SwitchMapMethodsGeneration.DefaultWithPartialOverloads,
MapMethods = SwitchMapMethodsGeneration.DefaultWithPartialOverloads)]
public partial class TextOrNumber;
// Use partial coverage with default case
union.SwitchPartially(
@default: value => Console.WriteLine($"Default: {value}"),
@string: text => Console.WriteLine($"Text: {text}")
);
var result = union.MapPartially(
@default: "Default case",
@string: "Text"
);To completely disable the generation of Switch or Map methods, use SwitchMapMethodsGeneration.None:
[Union<string, int>(
SwitchMethods = SwitchMapMethodsGeneration.None,
MapMethods = SwitchMapMethodsGeneration.None)]
public partial class TextOrNumber;The SwitchMapMethodsGeneration enum values are:
-
None-- no Switch/Map methods are generated -
Default-- only exhaustive overloads are generated (the default) -
DefaultWithPartialOverloads-- both exhaustive and partial overloads are generated
The state parameter in generated Switch/Map methods (used to avoid closures) is named state by default. You can customize this name using SwitchMapStateParameterName. This property is available on both ad-hoc unions (UnionAttribute<T1, T2> / AdHocUnionAttribute) and regular unions (UnionAttribute).
[Union<string, int>(SwitchMapStateParameterName = "context")]
public partial class TextOrNumber;The source generator creates factory methods (Create{MemberName}) for ad-hoc union members under certain conditions. By default, factory methods are generated for all members whenever any member triggers their generation. The trigger conditions are:
- A member is a type parameter (C# does not allow conversion operators for type parameters)
- A member is an interface type
- A member is
System.Object - Two or more members share the same type (duplicate)
// T is a type parameter, which triggers factory methods for ALL members
[Union<TypeParamRef1, string>]
public partial struct Result<T>;
// Generated factory methods:
// Result<T>.CreateT(T value) — for the type parameter member
// Result<T>.CreateString(string value) — for the concrete type member (also has implicit conversion)
// Usage
Result<int> success = Result<int>.CreateT(42); // factory method (no implicit conversion for type params)
Result<int> error = "Something failed"; // implicit conversion still works for string
Result<int> alsoError = Result<int>.CreateString("err"); // factory method also availableYou can control this behavior with the FactoryMethodGeneration property on the union attribute:
FactoryMethodGeneration.Always -- generate factory methods for all members unconditionally, even when no trigger condition is present:
[Union<string, int>(FactoryMethodGeneration = FactoryMethodGeneration.Always)]
public partial class TextOrNumber;
// Generated factory methods:
// TextOrNumber.CreateString(string value)
// TextOrNumber.CreateInt32(int value)
// Implicit conversions are still generated as usual
TextOrNumber fromString = "hello"; // implicit conversion
TextOrNumber alsoFromString = TextOrNumber.CreateString("hello"); // factory methodFactoryMethodGeneration.None -- suppress all factory method generation, even when triggers are present:
[Union<TypeParamRef1, string>(FactoryMethodGeneration = FactoryMethodGeneration.None)]
public partial struct Result<T>;
// No factory methods generated — only constructors are available for type parameter members
Result<int> success = new Result<int>(42); // constructor
Result<int> error = "Something failed"; // implicit conversion for stringNote: When using
FactoryMethodGeneration.Nonewith type parameter or duplicate members, you are responsible for providing your own creation methods if needed, since conversion operators cannot be generated for those members.
Factory methods for stateless members (marked with TXIsStateless = true) are parameterless:
[Union<string, EmptyState>(
T2IsStateless = true,
FactoryMethodGeneration = FactoryMethodGeneration.Always)]
public partial class TextOrEmpty;
public readonly record struct EmptyState;
// Generated:
// TextOrEmpty.CreateString(string value)
// TextOrEmpty.CreateEmptyState() — parameterless because EmptyState is statelessThe access modifier of factory methods follows the ConstructorAccessModifier setting.
You can completely disable generation of equality comparison members using SkipEqualityComparison:
// Generic approach
[Union<string, int>(SkipEqualityComparison = true)]
public partial class TextOrNumber;
// Non-generic approach
[AdHocUnion(typeof(string), typeof(int), SkipEqualityComparison = true)]
public partial class TextOrNumber;When SkipEqualityComparison is set to true, the source generator will not generate:
-
Equalsmethod overrides (bothEquals(object?)andEquals(T)) -
GetHashCodemethod override - Equality operators (
==and!=) -
IEquatable<T>interface implementation -
IEqualityOperators<T, T, bool>interface implementation
Use with caution: After setting
SkipEqualityComparison = true, you are responsible for implementing custom equality members if needed.
Use cases:
- When you need custom equality logic that differs from structural equality
- When integrating with systems that require reference equality
- When implementing wrapper types with specific equality semantics
Note:
SkipEqualityComparisononly applies to ad-hoc unions. Regular (inheritance-based) unions do not support this option as their equality behavior is controlled by standard C# inheritance rules.
Regular unions support nesting (unions within unions) but have specific rules that differ between classes and records:
When using classes for nested unions, derived types must follow these rules:
- Non-sealed derived classes must have all constructors private
- Classes can contain further nested unions (both with and without
[Union]attribute) - All derived types must be non-generic
- Non-abstract derived types cannot have lower accessibility than the base union
[Union]
public partial class ApiResponse
{
public sealed class Success : ApiResponse
{
public string Data { get; }
public Success(string data) => Data = data;
}
// Classes can be used for intermediate nesting
// Note: This class can optionally have [Union] attribute or be abstract
public class Error : ApiResponse
{
private Error() { } // Private constructor required for non-sealed classes
public sealed class ValidationError : Error
{
public string Message { get; }
public ValidationError(string message) => Message = message;
}
public sealed class ServerError : Error
{
public int StatusCode { get; }
public ServerError(int statusCode) => StatusCode = statusCode;
}
}
}Alternative with explicit union attribute:
[Union]
public partial class ApiResponse
{
public sealed class Success : ApiResponse { /* ... */ }
// When [Union] attribute is applied, the source generator makes the class abstract
[Union]
public partial class Error : ApiResponse // Will be generated as abstract
{
// Private constructor will be generated automatically
public sealed class ValidationError : Error { /* ... */ }
public sealed class ServerError : Error { /* ... */ }
}
}Records have stricter nesting rules due to C# language limitations:
- All derived records must be sealed
- Records cannot contain nested unions because they must be sealed
- Use classes if you need nested unions
[Union]
public partial record ApiResponse
{
public sealed record Success(string Data) : ApiResponse;
// This will cause a compile error - records must be sealed
public abstract record Error : ApiResponse; // Error: UnionRecordMustBeSealed
// Correct approach - use sealed records only (no nesting possible)
public sealed record ValidationError(string Message) : ApiResponse;
public sealed record ServerError(int StatusCode) : ApiResponse;
}In some cases, you may want to handle a subset of union cases without providing handlers for all of them.
This is especially useful in nested union scenarios where you want to delegate the handling of a group of cases to a separate method.
The [UnionSwitchMapOverload] attribute allows you to generate non-exhaustive Switch and Map overloads that only handle a specific set of union cases.
The attribute takes an array of types (StopAt) that defines the "boundary" for the generated overloads.
The overloads will include the specified types and their siblings, but not any types that derive from them.
Use with caution: The non-exhaustive overloads sacrifice compile-time exhaustiveness -- the main safety benefit of discriminated unions. If new derived types are added to the union later, the compiler will not warn you that the non-exhaustive overload doesn't handle them. Prefer the default exhaustive
Switch/Mapmethods whenever possible, and limit[UnionSwitchMapOverload]to cases where delegating a group of types to a dedicated handler (as shown below) genuinely simplifies the code.
Consider the following ApiResponse union, which has a nested Failure union:
[Union]
[UnionSwitchMapOverload(StopAt = [typeof(Failure)])]
public partial class ApiResponse
{
public sealed class Success : ApiResponse;
[Union]
public abstract partial class Failure : ApiResponse
{
private Failure()
{
}
public sealed class NotFound : Failure;
public sealed class Unauthorized : Failure;
}
}By applying [UnionSwitchMapOverload(StopAt = [typeof(Failure)])], the source generator will create an additional Switch/Map overload that accepts handlers for Success and Failure, but not for NotFound or Unauthorized.
This allows you to handle the Failure case in a dedicated method, like this:
var apiResponse = new ApiResponse.Failure.Unauthorized();
apiResponse.Switch(
success: success => logger.Information("[Switch] Success"),
failure: HandleFailure);
void HandleFailure(ApiResponse.Failure failure)
{
failure.Switch(
failureNotFound: notFound => logger.Information("[Switch] Not Found"),
failureUnauthorized: unauthorized => logger.Information("[Switch] Unauthorized")
);
}Without the [UnionSwitchMapOverload] attribute, you would have to handle all leaf-level cases (Success, NotFound, Unauthorized) in a single Switch statement, which can become cumbersome in more complex scenarios.
When you have nested Regular Unions, the generated Switch and Map methods include parameters for each possible type. By default, parameter names include intermediate type names to avoid conflicts, but you can configure this behavior using the NestedUnionParameterNames property.
By default, nested types include their parent union names in parameter names to ensure uniqueness:
[Union]
public partial class ApiResponse
{
public sealed class Success : ApiResponse;
public abstract partial class Failure : ApiResponse
{
private Failure() { }
public sealed class NotFound : Failure;
public sealed class Unauthorized : Failure;
}
}
// Usage - note the parameter names include "failure" prefix
response.Switch(
success: () => Console.WriteLine("Success!"),
failureNotFound: () => Console.WriteLine("Not found"), // Includes "failure" prefix
failureUnauthorized: () => Console.WriteLine("Unauthorized") // Includes "failure" prefix
);This naming strategy ensures that even if you have multiple nested unions with types of the same name, the generated parameter names won't conflict.
For shorter, more concise parameter names, use NestedUnionParameterNameGeneration.Simple:
[Union(NestedUnionParameterNames = NestedUnionParameterNameGeneration.Simple)]
public partial class ApiResponse
{
public sealed class Success : ApiResponse;
public abstract partial class Failure : ApiResponse
{
private Failure() { }
public sealed class NotFound : Failure;
public sealed class Unauthorized : Failure;
}
}
// Usage - note the simpler parameter names
response.Switch(
success: () => Console.WriteLine("Success!"),
notFound: () => Console.WriteLine("Not found"), // Simple type name only
unauthorized: () => Console.WriteLine("Unauthorized") // Simple type name only
);- Default: Best for complex union hierarchies with many nested unions where name conflicts are likely. This is the safer choice and maintains backward compatibility.
- Simple: Best for simpler hierarchies where type names are unique and you prefer shorter, more readable parameter names.
Simple mode can cause compilation errors if multiple nested unions contain types with the same name.
This example will fail to compile:
[Union(NestedUnionParameterNames = NestedUnionParameterNameGeneration.Simple)]
public partial class Response
{
public abstract partial class ErrorA : Response
{
private ErrorA() { }
public sealed class NotFound : ErrorA; // Parameter: notFound
}
public abstract partial class ErrorB : Response
{
private ErrorB() { }
public sealed class NotFound : ErrorB; // Parameter: notFound (CONFLICT!)
}
}Error: C# compiler will report duplicate parameter name notFound in the generated Switch/Map methods.
If you encounter this error, either:
- Switch back to
Defaultmode (or remove the property entirely) - Rename one of the conflicting types
- Use
UnionSwitchMapOverloadto create separate Switch/Map overloads that don't include both types
The NestedUnionParameterNames property works correctly with UnionSwitchMapOverload:
[Union(NestedUnionParameterNames = NestedUnionParameterNameGeneration.Simple)]
[UnionSwitchMapOverload(StopAt = [typeof(Failure)])]
public partial class ApiResponse
{
public sealed class Success : ApiResponse;
[Union]
public abstract partial class Failure : ApiResponse
{
private Failure() { }
public sealed class NotFound : Failure;
public sealed class Unauthorized : Failure;
}
}
// Two overloads are generated:
// 1. Stopped at Failure level (default parameter names)
response.Switch(
success: () => Console.WriteLine("Success!"),
failure: HandleFailure // Accepts any Failure type
);
// 2. Full expansion (uses simple parameter names)
response.Switch(
success: () => Console.WriteLine("Success!"),
notFound: () => Console.WriteLine("Not found"), // Simple name
unauthorized: () => Console.WriteLine("Unauthorized") // Simple name
);Several attribute settings have cascading effects on other settings:
| Setting | Cascading Effect |
|---|---|
TXIsStateless = true |
Automatically sets TXIsNullableReferenceType = true for reference types (since default(T) for reference types is null) |
ConstructorAccessModifier |
Also applies to the generated implicit conversion operators and factory methods — e.g. Private makes constructors, implicit conversions, and factory methods private |
FactoryMethodGeneration |
None suppresses all factory methods — including for type parameters and duplicates. Always generates for all members even without triggers. Default auto-detects based on trigger conditions and generates for all members when any trigger is present |
- Home
- Smart Enums
- Value Objects
- Discriminated Unions
- Object Factories
- Analyzer Diagnostics
- Source Generator Configuration
- Convenience methods and classes
- Migrations
- Version 7
- Version 8