Skip to content

Discriminated Unions Customization

Pawel Gerr edited this page Mar 27, 2026 · 3 revisions

Discriminated unions are highly customizable. This page covers all customization options for both ad hoc and regular unions.

Ad Hoc Union Customization

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.

Renaming of properties

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;

Default string comparison

Configure string comparison behavior:

[Union<string, int>(DefaultStringComparison = StringComparison.Ordinal)]
public partial class TextOrNumber;

Skip implementation of ToString

Disable ToString generation:

[Union<string, int>(SkipToString = true)]
public partial class TextOrNumber;

Constructor access modifier

Control constructor accessibility:

This feature is useful in conjunction with ConversionFromValue = ConversionOperatorsGeneration.None for 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)

Customizing conversion operators

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 object or interface types. If a member type is object or 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;
    }
}

Definition of nullable reference types

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?

Single backing field

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 field

UseSingleBackingField = 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, UseSingleBackingField mainly affects value types — it forces them into the shared field as well, at the cost of boxing.

Stateless types for memory optimization

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, whereas default(MyClass) is null, requiring null checks
  • Simpler code: No need to handle nullable reference types or worry about null dereference

Important notes:

  • Stateless types automatically set TXIsNullableReferenceType = true for reference types (since default(T) for reference types is null)
  • Accessors like AsT1 and Value return default(T) for stateless types
  • Equality comparison for stateless types is based solely on discriminator matching
  • Structs used as stateless types are typically empty readonly struct types

Pattern matching with partial coverage

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;

Factory method generation

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 available

You 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 method

FactoryMethodGeneration.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 string

Note: When using FactoryMethodGeneration.None with 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 stateless

The access modifier of factory methods follows the ConstructorAccessModifier setting.

Skipping equality comparison

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:

  • Equals method overrides (both Equals(object?) and Equals(T))
  • GetHashCode method 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: SkipEqualityComparison only 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 Union Customization

Nesting unions

Regular unions support nesting (unions within unions) but have specific rules that differ between classes and records:

Nesting rules for classes

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 { /* ... */ }
    }
}

Nesting rules for records

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;
}

Generating non-exhaustive Switch/Map overloads

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/Map methods 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.

Configuring nested union parameter names

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.

Default Mode

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.

Simple Mode

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
);

When to Use Each Mode

  • 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.

Warning: Potential Name Conflicts

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:

  1. Switch back to Default mode (or remove the property entirely)
  2. Rename one of the conflicting types
  3. Use UnionSwitchMapOverload to create separate Switch/Map overloads that don't include both types

Combining with UnionSwitchMapOverload

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
);

Setting Dependencies

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

Clone this wiki locally