A Smart Enum looks simple until we try to turn it into a genuinely closed domain type.
This article continues enum vs Smart Enum in C#: when an enumeration is enough and when you need a domain type. That article covered when each option fits. Here we will build a complete Grade implementation using sealed record.
The first attempt: a positional record
We could start with:
public sealed record Grade(
int Id,
string Label,
double Gpa,
bool IsPassing);
It is compact and automatically gets value equality.
But it has an important problem:
var fake = new Grade(
Id: 999,
Label: "Z",
Gpa: 99,
IsPassing: true);
The constructor is public. The type therefore does not control which values can exist.
If Grade is meant to behave like a richer enumeration, construction must be controlled.
Complete implementation
A more robust version can look like this:
using System.Diagnostics.CodeAnalysis;
public sealed record Grade
{
public static Grade A { get; } =
new(1, "A", 4.0, true);
public static Grade B { get; } =
new(2, "B", 3.0, true);
public static Grade C { get; } =
new(3, "C", 2.0, true);
public static Grade D { get; } =
new(4, "D", 1.0, true);
public static Grade F { get; } =
new(5, "F", 0.0, false);
public int Id { get; }
public string Label { get; }
public double Gpa { get; }
public bool IsPassing { get; }
private Grade(
int id,
string label,
double gpa,
bool isPassing)
{
Id = id;
Label = label;
Gpa = gpa;
IsPassing = isPassing;
}
public static IReadOnlyList<Grade> All { get; } =
[A, B, C, D, F];
private static readonly IReadOnlyDictionary<int, Grade> ById =
All.ToDictionary(x => x.Id);
private static readonly IReadOnlyDictionary<string, Grade> ByLabel =
All.ToDictionary(
x => x.Label,
StringComparer.OrdinalIgnoreCase);
public static Grade FromId(int id) =>
TryFromId(id, out var grade)
? grade
: throw new ArgumentOutOfRangeException(
nameof(id),
id,
"Invalid grade.");
public static bool TryFromId(
int id,
[NotNullWhen(true)] out Grade? grade)
{
if (ById.TryGetValue(id, out var found))
{
grade = found;
return true;
}
grade = null;
return false;
}
public static Grade Parse(string label)
{
ArgumentException.ThrowIfNullOrWhiteSpace(label);
return TryParse(label, out var grade)
? grade
: throw new ArgumentException(
$"'{label}' is not a valid grade.",
nameof(label));
}
public static bool TryParse(
string? label,
[NotNullWhen(true)] out Grade? grade)
{
if (!string.IsNullOrWhiteSpace(label) &&
ByLabel.TryGetValue(label.Trim(), out var found))
{
grade = found;
return true;
}
grade = null;
return false;
}
public override string ToString() => Label;
}
The public API stays small and predictable:
Grade grade = Grade.A;
Console.WriteLine(grade.Label); // A
Console.WriteLine(grade.Gpa); // 4
Console.WriteLine(grade.IsPassing); // True
We can reconstruct a value from a boundary:
Grade fromText = Grade.Parse("b");
Console.WriteLine(fromText == Grade.B);
// True
or avoid exceptions:
if (Grade.TryParse("C", out var grade))
{
Console.WriteLine(grade.Gpa);
}
And if persistence uses a stable identifier:
Grade grade = Grade.FromId(5);
Console.WriteLine(grade == Grade.F);
// True
Why the constructor must be private
This is the piece that makes the pattern behave like a closed enumeration.
With:
private Grade(...)
the consumer cannot write:
new Grade(999, "Z", 99, true);
The valid values are published explicitly:
Grade.A
Grade.B
Grade.C
Grade.D
Grade.F
The type controls construction and centralizes its invariants.
Why use a record
A record provides value equality semantics.
Grade.Parse("A") == Grade.A
returns true.
With a normal class, we would need to choose reference identity or implement Equals, GetHashCode, and IEquatable<Grade>.
For a concept that represents a domain value, structural equality is often useful.
A subtle detail: record does not mean closed set
The record keyword does not close the set on its own.
This remains open:
public sealed record Grade(
string Label,
double Gpa,
bool IsPassing);
because callers can invoke the constructor.
The set becomes closed through the combination of:
sealed record
+
private constructor
+
immutable properties
+
known static instances
A record can still create an equivalent copy through an empty with expression:
var copy = Grade.A with { };
That copy is not the same reference, but it is still the same value because its properties cannot be changed from outside.
If the domain requires strict singleton reference identity, a sealed class with explicit equality may be a better representation.
Why add an Id
We could identify grades only by Label:
"A"
"B"
"C"
but a stable identifier is useful for persistence:
1 -> A
2 -> B
3 -> C
4 -> D
5 -> F
The label can remain presentation or an external contract while the Id stays stable.
Still, the domain must define what identity actually means. Generated record equality compares all properties. If two instances should be equal only by Id, equality should be implemented explicitly rather than relying on the generated behavior.
All replaces Enum.GetValues
With an enum we have:
Enum.GetValues<Grade>()
With the Smart Enum we expose the set:
foreach (var grade in Grade.All)
{
Console.WriteLine(
$"{grade.Label}: {grade.Gpa}");
}
That also lets us build internal indexes and avoid repeated linear searches:
private static readonly IReadOnlyDictionary<string, Grade> ByLabel =
All.ToDictionary(
x => x.Label,
StringComparer.OrdinalIgnoreCase);
So TryParse does not need to scan All every time.
Parse and TryParse should have different contracts
Parse is convenient when an invalid value is an error:
Grade grade = Grade.Parse(input);
TryParse works better when failure is part of normal control flow:
if (!Grade.TryParse(input, out var grade))
{
// return 400, show validation, etc.
}
The attribute:
[NotNullWhen(true)]
also tells the compiler’s nullable analysis that grade is non-null when the method returns true.
JSON and external boundaries
An Enumeration Class does not automatically receive all the infrastructure that enum gets.
A simple strategy is to keep the rich type inside the domain and convert at the boundary:
public sealed record CreateStudentRequest(string Grade);
Then:
Grade grade = Grade.Parse(request.Grade);
If we want to serialize Grade directly as:
{
"grade": "A"
}
we will normally add a JsonConverter<Grade>.
The extra cost is real: a Smart Enum gains expressiveness, but we must define explicitly how it crosses JSON, databases, configuration, and other contracts.
EF Core
Persisting the Id can be done with a ValueConverter:
builder.Property(x => x.Grade)
.HasConversion(
grade => grade.Id,
id => Grade.FromId(id));
We could also persist Label:
builder.Property(x => x.Grade)
.HasConversion(
grade => grade.Label,
value => Grade.Parse(value));
The choice depends on which representation is the stable persistence contract.
What we get in the end
This implementation gives us:
- a controlled set of domain values;
- per-value metadata;
- value equality;
- explicit parsing;
- lookup by Id;
- enumeration through
All; - readable
ToString(); - one place for rules and invariants.
In exchange, we lose some of the infrastructure that comes automatically with enum: Enum.GetValues, Enum.TryParse, automatic serialization, direct ORM support, and some switch ergonomics.
The question remains the same: are we representing a constant or a domain concept?
For the full comparison, return to enum vs Smart Enum in C#: when an enumeration is enough and when you need a domain type.
References
- Microsoft Learn — Records
- Microsoft Learn — Enumeration types
- Microsoft Learn — Nullable static analysis attributes
- Microsoft Learn — Value conversions in EF Core