Desde C# 9, el lenguaje tiene un tipo nuevo pensado para representar datos: el record. La idea es sencilla pero potente: en lugar de definir una clase con propiedades, un constructor y métodos de igualdad escritos a mano, declaras el record en una línea y el compilador hace el resto.
record Person(string Name, string Email);
Con esa única línea obtienes un tipo con propiedades de solo lectura, un constructor, igualdad por valor, GetHashCode(), ToString() y deconstrucción. La igualdad por valor es la parte que más cambia respecto a las clases normales:
var p1 = new Person("Ana", "[email protected]");
var p2 = new Person("Ana", "[email protected]");
Console.WriteLine(p1 == p2); // true
Con una clase normal el resultado sería false porque las referencias son distintas. Con un record compara los valores de las propiedades, que es lo que casi siempre quieres cuando trabajas con datos.
record class vs record struct
Por defecto, un record es un tipo de referencia, equivalente a escribir record class. Si necesitas un tipo de valor pequeño e inmutable, puedes usar record struct:
record struct Point(double X, double Y);
La diferencia práctica es la misma que entre clase y struct: el record struct se copia por valor, vive en la pila cuando es local y no genera presión en el recolector de basura. Úsalo para cosas pequeñas como coordenadas, rangos de fechas o colores. Para modelos más complejos, el record class es la opción habitual.
La expresión with: copiar con cambios
Como los records son inmutables no puedes modificarlos directamente, pero sí puedes crear una copia con algún campo diferente usando la expresión with:
var original = new Person("Ana", "[email protected]");
var updated = original with { Email = "[email protected]" };
El objeto original no cambia. updated es una copia nueva donde solo el email es distinto. Esto viene de perlas en código que modela estados: en vez de mutar el estado actual, produces el siguiente estado como un valor nuevo. El historial de cambios queda explícito y el código es más fácil de razonar.
Cuándo usar records, cuándo clases y cuándo structs
No hay una respuesta única, pero hay algunas pistas claras:
- Record: modelos de datos sin lógica de negocio compleja, DTOs para APIs, Value Objects de DDD, resultados de operaciones. Cuando el dato es lo que importa, no la identidad del objeto.
- Clase: entidades con ciclo de vida propio, lógica de negocio que necesita estado mutable, jerarquías de herencia donde quieres control fino del comportamiento.
- Struct: valores pequeños que se copian mucho (coordenadas, colores, identificadores simples) y donde el coste de la gestión de memoria importa.
En la práctica, si estás modelando la respuesta de una API o los parámetros de entrada de un comando, los records son casi siempre la opción más limpia.
Pattern matching: switch expressions desde C# 8
El pattern matching en C# empezó en la versión 7 con el operador is mejorado, pero donde realmente cambió la forma de escribir código fue con las switch expressions de C# 8. A diferencia del switch clásico, la expresión devuelve un valor directamente:
double area = forma switch
{
Circulo c => Math.PI * c.Radio * c.Radio,
Rectangulo r => r.Ancho * r.Alto,
_ => throw new ArgumentException("Forma desconocida")
};
El compilador verifica exhaustividad: si añades un nuevo tipo de forma y olvidas el caso correspondiente, el compilador avisa. Con el switch clásico ese fallo solo se detecta en tiempo de ejecución.
Type patterns y relational patterns (C# 9)
El type pattern combina la verificación de tipo con el casting en un solo paso:
if (animal is Cat cat)
{
cat.Purr();
}
Si animal es un Cat, la variable cat queda disponible ya casteada. No hay doble cast ni variable temporal.
Los relational patterns de C# 9 permiten usar comparaciones directamente dentro del patrón:
string categoria = edad switch
{
>= 18 => "adulto",
>= 13 => "adolescente",
_ => "niño"
};
Y puedes combinar patrones con los operadores lógicos and, or y not:
bool valido = numero is > 0 and <= 100;
bool fuera = numero is not (>= 0 and <= 100);
Property patterns y extended property patterns
El property pattern verifica las propiedades de un objeto sin necesidad de extraerlas antes:
if (order is { Status: "Active", Total: > 1000 })
{
AplicarDescuento(order);
}
C# 10 amplió esto con los extended property patterns, que permiten acceder a propiedades anidadas directamente en el patrón:
if (order is { Customer.Address.Country: "ES" })
{
AplicarIVA(order);
}
Antes de C# 10 tenías que desestructurar Customer en un paso intermedio o encadenar comprobaciones. Ahora queda en una sola expresión.
List patterns (C# 11)
Con C# 11 puedes hacer pattern matching sobre arrays y listas:
// Exactamente dos elementos
if (lista is [var primero, var segundo]) { ... }
// Primer y último elemento con cualquier cosa en medio
if (lista is [var cabeza, .., var cola]) { ... }
// Lista vacía
if (lista is []) { ... }
// Empieza por un valor concreto
if (lista is [1, ..]) { ... }
El operador .. actúa como comodín para el resto de los elementos. Es especialmente útil cuando procesas secuencias de comandos, tokens o registros con una estructura conocida.
Guards con when
Cuando un patrón no basta y necesitas una condición adicional, añades un guard con when:
string resultado = persona switch
{
{ Edad: var e } when e >= 65 => "jubilado",
{ Edad: var e } when e >= 18 => "adulto",
{ Edad: var e } when e >= 13 => "adolescente",
_ => "niño"
};
El guard se evalúa solo si el patrón principal coincide, así que el orden de los casos importa.
Records en la práctica
DTO para una API
record CreateUserRequest(string Name, string Email, string Role);
// Uso en un endpoint de ASP.NET Core
app.MapPost("/users", (CreateUserRequest req) =>
{
// req es inmutable, no hay riesgo de que alguien lo mute por el camino
});
Value Object de DDD
record Money(decimal Amount, string Currency)
{
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException("Divisas distintas");
return this with { Amount = Amount + other.Amount };
}
}
var precio = new Money(10.00m, "EUR");
var impuesto = new Money(2.10m, "EUR");
var total = precio.Add(impuesto); // Money(12.10, "EUR")
Result type sin dependencias externas
record Result<T>(bool Success, T? Value, string? Error)
{
public static Result<T> Ok(T value) => new(true, value, null);
public static Result<T> Fail(string msg) => new(false, default, msg);
}
// En un método de servicio
Result<User> resultado = await userService.GetByEmail(email);
string mensaje = resultado switch
{
{ Success: true, Value: var user } => $"Bienvenido, {user.Name}",
{ Error: var err } => $"Error: {err}"
};
La combinación de records y pattern matching aquí es especialmente limpia: el record define la estructura del resultado y el switch expression lo desestructura sin ifs encadenados.
Compatibilidad y versiones
Por si necesitas saber qué tienes disponible según la versión de C# que uses:
- C# 7:
iscon tipo y variable, switch con type patterns básicos. - C# 8: switch expressions, property patterns, tuple patterns.
- C# 9: records, relational patterns, logical patterns (
and,or,not). - C# 10: extended property patterns,
record struct. - C# 11: list patterns.
- C# 12: primary constructors en clases (similar en sintaxis a los records).
Si trabajas con .NET 6 o superior ya tienes C# 10 disponible. Con .NET 8 (LTS) tienes C# 12 completo.
Recursos relacionados
Imagen: Pexels / Markus Spiske
