RequiredEnum
Regular C# enums are fast and familiar, but they are weak at domain modeling:
- invalid casts are possible
- behavior has to live somewhere else
- wire names and display names get bolted on afterward
RequiredEnum<TSelf> solves that by giving you a finite symbolic set with behavior.
Start with a working example
using Trellis;
namespace RequiredEnumExamples;
public partial class OrderStatus : RequiredEnum<OrderStatus>
{
public static readonly OrderStatus Draft = new(canShip: false, isTerminal: false);
[EnumValue("awaiting-payment")]
public static readonly OrderStatus AwaitingPayment = new(canShip: false, isTerminal: false);
public static readonly OrderStatus Paid = new(canShip: true, isTerminal: false);
public static readonly OrderStatus Shipped = new(canShip: false, isTerminal: false);
public static readonly OrderStatus Cancelled = new(canShip: false, isTerminal: true);
private OrderStatus(bool canShip, bool isTerminal)
{
CanShip = canShip;
IsTerminal = isTerminal;
}
public bool CanShip { get; }
public bool IsTerminal { get; }
}
Usage stays simple:
using RequiredEnumExamples;
var paid = OrderStatus.Paid;
var parsed = OrderStatus.TryCreate("awaiting-payment");
var shipped = OrderStatus.TryFromName("Shipped");
var all = OrderStatus.GetAll();
bool readyToShip = paid.CanShip;
bool isOpenState = paid.Is(OrderStatus.Draft, OrderStatus.AwaitingPayment, OrderStatus.Paid);
bool isNotTerminal = paid.IsNot(OrderStatus.Cancelled);
Why teams reach for RequiredEnum<TSelf>
It helps when your “enum” has to do more than hold an integer:
- state transitions
- policy flags
- wire-name overrides
- JSON/model binding
- richer equality semantics
What the API actually gives you
RequiredEnum<TSelf> exposes these important members:
| Member | Purpose |
|---|---|
Value |
symbolic identity |
Ordinal |
declaration-order metadata only |
GetAll() |
returns every declared member |
TryFromName(...) |
case-insensitive symbolic lookup |
Is(params TSelf[]) |
membership check |
IsNot(params TSelf[]) |
negated membership check |
The source generator also adds:
TryCreate(string value)TryCreate(string? value, string? fieldName = null)Create(string value)- parsing and JSON support
Note
Generated TryCreate(...) delegates to TryFromName(...). There is no separate TryFromValue(...) API path in the current Trellis implementation.
Value and Ordinal mean different things
This distinction is easy to miss:
Valueis the semantic identityOrdinalis just declaration-order metadata
Ordinal is useful for diagnostics or display ordering, but it should not be treated as a stable wire contract.
Default names vs [EnumValue]
By default, the symbolic value is the field name:
using RequiredEnumExamples;
bool usesFieldName = OrderStatus.Paid.Value == "Paid";
Use [EnumValue(...)] only when the external symbolic name must differ:
using RequiredEnumExamples;
bool usesOverride = OrderStatus.AwaitingPayment.Value == "awaiting-payment";
That keeps one source of truth most of the time.
Adding behavior is the real win
This is where RequiredEnum<TSelf> beats a raw enum.
public partial class InvoiceStatus : RequiredEnum<InvoiceStatus>
{
public static readonly InvoiceStatus Draft = new(canPost: false);
public static readonly InvoiceStatus Approved = new(canPost: true);
public static readonly InvoiceStatus Posted = new(canPost: false);
private InvoiceStatus(bool canPost) => CanPost = canPost;
public bool CanPost { get; }
}
Now the behavior travels with the symbolic value instead of being scattered through switch statements.
State-machine style modeling
RequiredEnum<TSelf> works especially well for workflows:
using Trellis;
namespace WorkflowExample;
public partial class ShipmentStatus : RequiredEnum<ShipmentStatus>
{
public static readonly ShipmentStatus Draft = new();
public static readonly ShipmentStatus Ready = new();
public static readonly ShipmentStatus Sent = new();
public static readonly ShipmentStatus Delivered = new();
private ShipmentStatus() { }
public bool CanTransitionTo(ShipmentStatus next) =>
this switch
{
_ when this == Draft => next.Is(Ready),
_ when this == Ready => next.Is(Sent),
_ when this == Sent => next.Is(Delivered),
_ => false
};
}
Serialization and web input
When you declare a concrete type as partial, the generator provides the plumbing for:
- JSON conversion
- ASP.NET Core model binding
- parsing helpers
That means "Paid" or "awaiting-payment" can flow naturally through APIs without hand-written converters.
EF Core persistence
Persist the symbolic Value, not Ordinal.
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace RequiredEnumEfExample;
public sealed class Order
{
public int Id { get; set; }
public RequiredEnumExamples.OrderStatus Status { get; set; } = null!;
}
public sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.Property(order => order.Status)
.HasConversion(
status => status.Value,
value => RequiredEnumExamples.OrderStatus.Create(value))
.IsRequired();
}
}
Warning
Persisting Ordinal turns declaration order into a storage contract. That is usually a mistake.
Best practices
- Declare members as
public static readonly - Keep constructors private
- Prefer field names as the default symbolic value
- Use
[EnumValue(...)]only for true wire-name overrides - Put state behavior on the type itself
- Use
Is(...)andIsNot(...)for readable membership checks
Summary
Use RequiredEnum<TSelf> when you need a finite set of domain values that:
- must be valid
- may carry behavior
- need stable symbolic names
- should work cleanly with JSON and model binding
If you just need an integer-backed constant, a regular enum is fine. If the values are part of your domain language, RequiredEnum<TSelf> is usually the better fit.