meetings/working-groups/discriminated-unions/extended-enums.md
This proposal introduces enhanced enums as an elegant way to build discriminated unions in C#. Building on type unions, enhanced enums provide familiar, concise syntax for algebraic sum types where cases are known at declaration time.
It consolidates design feedback from many years of repository discussions, especially #113 and related issues.
<details> <summary>Key discussion threads...</summary>C# gains a layered approach to union types: type unions provide the foundation for combining types, while enhanced enums offer elegant syntax for discriminated unions where you define cases and their union together.
// Type unions - combine existing types
union Result(string, ValidationError, NetworkException);
// Shape enums - discriminated unions with integrated case definitions
enum struct PaymentResult // or `enum class`
{
Success(string transactionId),
Declined(string reason),
PartialRefund(string originalId, decimal amount)
}
Type unions solve the fundamental problem of representing "one of several types". A particularly important pattern is discriminated unions, where:
Shape enums provide natural syntax for this pattern—expressing the entire discriminated union in a single declaration rather than manually defining and combining types.
Today's C# enums have significant limitations:
Enhanced enums address all these limitations while preserving conceptual simplicity.
By extending the existing enum keyword, enhanced enums provide a grow-up story. Simple enums remain simple, while advanced scenarios become possible without abandoning familiar patterns.
Type unions are fully specified in the unions proposal. They provide:
Enhanced enums extend traditional enum syntax in three orthogonal ways:
Support any constant-bearing type:
enum Traditional : int { A = 1, B = 2 }
enum Priority : string { Low = "low", Medium = "medium", High = "high" }
enum TranscendentalConstants : double { Pi = 3.14159, E = 2.71828 }
Create a shape enum (discriminated union) by:
class or struct after enumenum class Result { Success, Failure } // shape enum via 'class' keyword
enum struct Result { Success, Failure } // shape enum via 'struct' keyword
enum class Result // or enum struct
{
Success(string id),
Failure(int code, string message)
}
Each case with a parameter list generates a nested record type. enum class generates sealed record class types; enum struct generates readonly record struct types. While these are the generated types, the union implementation may optimize internal storage.
TODO: Should the structs be readonly? Seems like that goes against normal structs (including record struct). Seems like that could be opt in with readonly enum struct X.
// ✓ Valid - constant enum with string base
enum Status : string { Active = "A", Inactive = "I" }
// ✓ Valid - shape enum with data
enum class Result { Ok(int value), Error(string msg) }
// ✗ Invalid - cannot mix constants and shapes
enum class Bad { A = 1, B(string x) }
// ✗ Invalid - shape enums cannot have base types
enum struct Bad : int { A, B }
// ✗ Invalid - Constant enums cannot have parameter lists
enum Bad { A(), B() }
For the complete formal grammar, see Appendix A: Grammar Changes.
Enhanced constant enums support any primitive type with compile-time constants:
enum Priority : string
{
Low = "low",
Medium = "medium",
High = "high"
}
These compile to System.Enum subclasses with the appropriate value__ backing field. Non-integral constant enums require explicit values for each member.
Shape enums combine type unions with convenient integrated syntax:
enum class FileOperation // or enum struct
{
Open(string path),
Close,
Read(byte[] buffer, int offset, int count),
Write(byte[] buffer)
}
enum class WebResponse
{
Success(string content),
Error(int statusCode, string message),
Timeout
}
enum struct Option<T>
{
None,
Some(T value)
}
enum class creates discriminated unions with reference type cases:
enum struct creates discriminated unions with optimized value-type storage:
Enums can contain members just like unions:
enum class Result<T>
{
Success(T value),
Error(string message);
public bool IsSuccess => this switch
{
Success(_) => true,
_ => false
};
public T GetValueOrDefault(T defaultValue) => this switch
{
Success(var value) => value,
_ => defaultValue
};
}
Members are restricted to:
Shape enums translate directly to unions—generating case types as nested types and creating a union that combines them.
enum class Translationenum class Result
{
Success(string value),
Failure(int code)
}
// Translates to:
public union Result(Success, Failure)
{
public sealed record class Success(string value);
public sealed record class Failure(int code);
}
Singleton cases generate types with shared instances:
enum class State { Ready, Processing, Complete }
// Translates to:
public union State(Ready, Processing, Complete)
{
public sealed class Ready
{
public static readonly Ready Instance = new();
private Ready() { }
}
// Similar for Processing and Complete
}
enum struct Translationenum struct Option<T>
{
None,
Some(T value)
}
// Conceptually translates to:
public struct Option<T> : IUnion
{
public readonly struct None { }
public readonly record struct Some(T value);
// Optimized layout: discriminator + space for largest case
private byte _discriminant;
private T _value;
object? IUnion.Value => _discriminant switch
{
1 => new None(),
2 => new Some(_value),
_ => null
};
// Non-boxing access pattern
public bool TryGetValue(out None value)
{
value = default;
return _discriminant == 1;
}
public bool TryGetValue(out Some value)
{
if (_discriminant == 2)
{
value = new Some(_value);
return true;
}
value = default!;
return false;
}
// Constructors and factories
public Option(None _) => _discriminant = 1;
public Option(Some some) => (_discriminant, _value) = (2, some.value);
public static Option<T> None => new Option<T>(new None());
public static Option<T> Some(T value) => new Option<T>(new Some(value));
}
Shape enums inherit all union pattern matching behavior:
var message = operation switch
{
Open(var path) => $"Opening {path}",
Close => "Closing file",
Read(_, var offset, var count) => $"Reading {count} bytes at {offset}",
Write(var buffer) => $"Writing {buffer.Length} bytes"
};
The compiler tracks all declared cases. Both constant and shape enums can be open or closed (see Closed Enums proposal).
Open enums can be used to signal that the enum author may add new cases in future versions—consumers must handle unknown cases defensively (e.g., with a default branch). Closed enums signal that there is no need to handle unknown cases, such as when the case set is complete and will never change—the compiler ensures exhaustive matching without requiring a default case.
For constant enums, "open" also means values outside the declared set can be safely freely cast to the enum type.
closed enum Status { Active, Pending(DateTime since), Inactive }
// Compiler knows this is exhaustive - no default needed
var description = status switch
{
Active => "Currently active",
Pending(var date) => $"Pending since {date}",
Inactive => "Not active"
};
enum Priority : string { Low = "low", Medium = "medium", High = "high" }
// Default case is needed, other string values may be converted to Priority, or new cases may be added in the future:
var value = priority switch
{
Low => -1,
Medium => 0,
High => 1,
_ => /* fallback to low priority */ -1,
}
Shape enums automatically get:
// Traditional enum
enum OrderStatus { Pending = 1, Processing = 2, Shipped = 3, Delivered = 4 }
// Enhanced with data
enum struct OrderStatus
{
Pending,
Processing(DateTime startedAt),
Shipped(string trackingNumber),
Delivered(DateTime deliveredAt);
public bool IsComplete => this is Delivered;
}
enum class Result<T, E>
{
Ok(T value),
Error(E error);
public Result<U, E> Map<U>(Func<T, U> mapper) => this switch
{
Ok(var value) => Result<U, E>.Ok(mapper(value)),
Error(var err) => Result<U, E>.Error(err)
};
}
enum struct Option<T>
{
None,
Some(T value);
public T GetOrDefault(T defaultValue) => this switch
{
Some(var value) => value,
None => defaultValue
};
}
enum class ConnectionState
{
Disconnected,
Connecting(DateTime attemptStarted, int attemptNumber),
Connected(IPEndPoint endpoint, DateTime connectedAt),
Reconnecting(IPEndPoint lastEndpoint, int retryCount, DateTime nextRetryAt),
Failed(string reason, Exception exception);
public ConnectionState HandleTimeout() => this switch
{
Connecting(var started, var attempts) when attempts < 3 =>
ConnectionState.Reconnecting(null, attempts + 1, DateTime.Now.AddSeconds(Math.Pow(2, attempts))),
Connecting(_, _) =>
ConnectionState.Failed("Connection timeout", new TimeoutException()),
Connected(var endpoint, _) =>
ConnectionState.Reconnecting(endpoint, 1, DateTime.Now.AddSeconds(1)),
_ => this
};
}
enumShape enums are discriminated unions expressed through enum syntax. By building on union machinery:
The distinction between enum class and enum struct allows developers to choose the right trade-off, similar to choosing between record class and record struct.
enum class:
enum struct:
// Allocation per construction
enum class Result { Ok(int value), Error(string message) }
var r1 = Result.Ok(42); // Heap allocation
// No allocation
enum struct Result { Ok(int value), Error(string message) }
var r2 = Result.Ok(42); // Stack only
Shape enums benefit from all union optimizations:
partial for source generators?default(EnumType) produce for shape enums?Result.Ok(42) or new Result.Ok(42) or both?IEquatable<T>?enum_declaration
: attributes? enum_modifier* 'enum' ('class' | 'struct')? identifier enum_base? enum_body ';'?
;
enum_base
: ':' enum_underlying_type
;
enum_underlying_type
: simple_type // all integral types, fp-types, decimal, bool and char
| 'string'
| type_name // Must resolve to one of the above
;
enum_body
: '{' enum_member_declarations? '}'
| '{' enum_member_declarations ';' class_member_declarations '}'
;
enum_member_declarations
: enum_member_declaration (',' enum_member_declaration)*
;
enum_member_declaration
: attributes? identifier enum_member_initializer?
;
enum_member_initializer
: '=' constant_expression
| parameter_list
;