Un Smart Enum parece sencillo hasta que intentamos convertirlo en un tipo de dominio realmente cerrado.
Este artículo continúa la comparación de enum vs Smart Enum en C#: cuándo basta una enumeración y cuándo necesitas un tipo de dominio. Allí vimos cuándo conviene usar cada alternativa. Aquí vamos a construir una implementación completa de Grade usando sealed record.
El primer intento: un positional record
Podríamos empezar así:
public sealed record Grade(
int Id,
string Label,
double Gpa,
bool IsPassing);
Es compacto y obtiene igualdad por valor automáticamente.
Pero tiene un problema importante:
var fake = new Grade(
Id: 999,
Label: "Z",
Gpa: 99,
IsPassing: true);
El constructor es público. Por tanto, el tipo no controla qué valores pueden existir.
Si queremos que Grade funcione como una enumeración enriquecida, necesitamos controlar la construcción.
Implementación completa
Una versión más robusta puede ser:
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;
}
La API queda pequeña y predecible:
Grade grade = Grade.A;
Console.WriteLine(grade.Label); // A
Console.WriteLine(grade.Gpa); // 4
Console.WriteLine(grade.IsPassing); // True
También podemos reconstruir el valor desde una frontera:
Grade fromText = Grade.Parse("b");
Console.WriteLine(fromText == Grade.B);
// True
o sin excepciones:
if (Grade.TryParse("C", out var grade))
{
Console.WriteLine(grade.Gpa);
}
Y si la persistencia usa un identificador estable:
Grade grade = Grade.FromId(5);
Console.WriteLine(grade == Grade.F);
// True
Por qué el constructor debe ser privado
Esta es la pieza que convierte el patrón en algo parecido a una enumeración cerrada.
Con:
private Grade(...)
el consumidor no puede hacer:
new Grade(999, "Z", 99, true);
Los valores válidos se publican explícitamente:
Grade.A
Grade.B
Grade.C
Grade.D
Grade.F
El tipo controla la construcción y concentra las invariantes.
Por qué usar record
Un record aporta semántica de igualdad por valor.
Grade.Parse("A") == Grade.A
devuelve true.
Con una class normal tendríamos que decidir si usamos identidad de referencia o implementar Equals, GetHashCode e IEquatable<Grade>.
Para un concepto que representa un valor del dominio, la igualdad estructural suele ser una propiedad útil.
Un detalle sutil: record no significa conjunto cerrado
La palabra clave record no cierra el conjunto por sí misma.
Esto sigue siendo abierto:
public sealed record Grade(
string Label,
double Gpa,
bool IsPassing);
porque cualquiera puede llamar al constructor.
El conjunto se vuelve cerrado por la combinación de:
sealed record
+
constructor privado
+
propiedades inmutables
+
instancias estáticas conocidas
Incluso un record permite crear una copia equivalente con una expresión with vacía:
var copy = Grade.A with { };
Esa copia no es la misma referencia, pero sigue siendo el mismo valor porque sus propiedades no pueden modificarse desde fuera.
Si el dominio exige identidad estricta de singleton por referencia, una sealed class con igualdad explícita puede ser una representación más adecuada.
Por qué añadir Id
Podríamos identificar cada nota solo por Label:
"A"
"B"
"C"
pero un identificador estable ayuda cuando el valor se persiste:
1 -> A
2 -> B
3 -> C
4 -> D
5 -> F
La etiqueta puede ser presentación o contrato externo; el Id puede permanecer estable aunque esa representación cambie.
Aun así, hay que decidir qué constituye realmente la identidad del dominio. El record generado compara todas sus propiedades. Si necesitamos que dos instancias sean iguales solo por Id, entonces conviene implementar la igualdad explícitamente en lugar de aceptar la generada por defecto.
All reemplaza Enum.GetValues
Con un enum tenemos:
Enum.GetValues<Grade>()
Con el Smart Enum exponemos el conjunto:
foreach (var grade in Grade.All)
{
Console.WriteLine(
$"{grade.Label}: {grade.Gpa}");
}
Eso también permite construir índices internos para evitar búsquedas lineales repetidas:
private static readonly IReadOnlyDictionary<string, Grade> ByLabel =
All.ToDictionary(
x => x.Label,
StringComparer.OrdinalIgnoreCase);
Así TryParse no necesita recorrer All cada vez.
Parse y TryParse deberían tener contratos distintos
Parse es conveniente cuando un valor inválido constituye un error:
Grade grade = Grade.Parse(input);
TryParse funciona mejor cuando el fallo es parte normal del flujo:
if (!Grade.TryParse(input, out var grade))
{
// responder 400, mostrar validación, etc.
}
El atributo:
[NotNullWhen(true)]
también informa al análisis de nullability del compilador de que grade no será null cuando el método devuelva true.
JSON y fronteras externas
Una Enumeration Class no recibe automáticamente todo el soporte que tiene enum.
Una estrategia simple es mantener el tipo rico dentro del dominio y convertir en la frontera:
public sealed record CreateStudentRequest(string Grade);
Después:
Grade grade = Grade.Parse(request.Grade);
Si queremos serializar Grade directamente como:
{
"grade": "A"
}
entonces normalmente añadiremos un JsonConverter<Grade>.
El coste adicional es real: un Smart Enum gana expresividad, pero requiere definir explícitamente cómo cruza JSON, bases de datos, configuración y otros contratos.
EF Core
Persistir el Id puede hacerse con un ValueConverter:
builder.Property(x => x.Grade)
.HasConversion(
grade => grade.Id,
id => Grade.FromId(id));
También podríamos persistir Label:
builder.Property(x => x.Grade)
.HasConversion(
grade => grade.Label,
value => Grade.Parse(value));
La elección depende de qué valor sea el contrato estable de persistencia.
Qué obtenemos al final
Esta implementación nos da:
- un conjunto controlado de valores del dominio;
- metadata asociada a cada valor;
- igualdad por valor;
- parsing explícito;
- búsqueda por Id;
- enumeración mediante
All; - una representación legible con
ToString(); - un único lugar para concentrar reglas e invariantes.
A cambio, perdemos parte de la infraestructura automática de enum: Enum.GetValues, Enum.TryParse, serialización automática, soporte directo de ORMs y la ergonomía de algunos patrones de switch.
La pregunta sigue siendo la misma: ¿estamos representando una constante o un concepto del dominio?
Para la comparación completa de ambos enfoques, vuelve a enum vs Smart Enum en C#: cuándo basta una enumeración y cuándo necesitas un tipo de dominio.
Referencias
- Microsoft Learn — Records
- Microsoft Learn — Enumeration types
- Microsoft Learn — Nullable static analysis attributes
- Microsoft Learn — Value conversions in EF Core