Un record es un tipo cuyo trabajo principal es contener datos, y cuya igualdad la definen esos datos. Los records llegaron en C# 9. Escribes una línea, y el compilador genera los miembros que necesita una clase de datos: propiedades, un constructor, Equals y == basados en el valor, GetHashCode, un ToString legible, Deconstruct y el soporte para copias con with.
Los records necesitan C# 9 o posterior (.NET 5+), así que el código con records de esta página se muestra como C# normal, con su salida en comentarios. La última sección escribe los mismos miembros a mano en C# 7, y eso sí puedes ejecutarlo.
Records posicionales
La forma más corta enumera las propiedades entre paréntesis después del nombre. Cada parámetro se convierte en una propiedad pública init-only con el mismo nombre:
public record Product(string Sku, string Name, decimal Price);
var mug = new Product("MUG-01", "Mug", 8.50m);
Console.WriteLine(mug.Name); // Mug
Console.WriteLine(mug); // Product { Sku = MUG-01, Name = Mug, Price = 8.50 }
// mug.Price = 4m; // error CS8852: init-only property
var (sku, name, price) = mug; // generated Deconstruct
Console.WriteLine($"{sku} {price}"); // MUG-01 8.50
A partir de esa única línea, el compilador genera:
- un constructor que recibe
(string Sku, string Name, decimal Price); - tres propiedades
public ... { get; init; }; Equals(object),Equals(Product),GetHashCode()y los operadores==y!=, que comparan las tres propiedades;ToString(), que imprime el nombre del tipo y todas las propiedades públicas;Deconstruct(out string Sku, out string Name, out decimal Price);- un constructor de copia (protected, o private en un record sealed) y un método de clonación oculto que usa
with.
Un record también puede escribirse con un cuerpo normal, lo que resulta práctico cuando las propiedades necesitan valores por defecto o validación:
public record Customer
{
public required string Email { get; init; } // required: C# 11
public string Name { get; init; } = "";
}
var c = new Customer { Email = "ana@example.com" };
Y las dos formas pueden combinarse: parámetros posicionales más miembros extra entre llaves.
public record Order(string Id, decimal Subtotal)
{
public decimal Tax => Subtotal * 0.23m;
public decimal Total => Subtotal + Tax;
}
Igualdad por valor
En una clase normal, == pregunta "¿son el mismo objeto?". En un record, pregunta "¿tienen los mismos valores?":
var a = new Product("MUG-01", "Mug", 8.50m);
var b = new Product("MUG-01", "Mug", 8.50m);
Console.WriteLine(a == b); // True
Console.WriteLine(a.Equals(b)); // True
Console.WriteLine(ReferenceEquals(a, b)); // False: still two objects
GetHashCode se genera de forma coherente, así que los records funcionan bien como claves de diccionario y en un HashSet<T>: un segundo record con los mismos valores encuentra la entrada del primero.
La igualdad compara cada campo (en los records posicionales, el campo que hay detrás de cada propiedad) con EqualityComparer<T>.Default, que llama al propio Equals del tipo. En una propiedad que es una colección, eso es igualdad de referencias, lo que sorprende a la gente:
public record Basket(string Owner, List<string> Items);
var x = new Basket("Ana", new List<string> { "tea" });
var y = new Basket("Ana", new List<string> { "tea" });
Console.WriteLine(x == y); // False: two different List objects
Si un record contiene una colección y debe compararse por su contenido, sobrescribe Equals(Basket other) y GetHashCode(), o usa una colección inmutable con una semántica de valor propia.
Expresiones with: cambios no destructivos
Los records suelen ser inmutables, así que "cambias" uno creando una copia modificada. with copia todas las propiedades y después aplica las asignaciones que van entre llaves:
var mug = new Product("MUG-01", "Mug", 8.50m);
var sale = mug with { Price = 6.00m };
Console.WriteLine(sale); // Product { Sku = MUG-01, Name = Mug, Price = 6.00 }
Console.WriteLine(mug.Price); // 8.50: the original is untouched
La copia es superficial. Una propiedad de tipo de referencia se copia como referencia, así que los dos records comparten el objeto:
public record Customer { public List<string> Tags { get; init; } = new(); /* ... */ }
var c1 = new Customer { Email = "ana@example.com", Tags = { "vip" } };
var c2 = c1 with { Name = "Ana" };
c2.Tags.Add("newsletter");
Console.WriteLine(string.Join(",", c1.Tags)); // vip,newsletter
O bien mantén las propiedades del record inmutables hasta el fondo (IReadOnlyList<T> rellenada una vez, o ImmutableList<T>), o bien crea una lista nueva en el with: c1 with { Tags = new List<string>(c1.Tags) }.
ToString
El ToString generado imprime el nombre del tipo y todas las propiedades públicas, lo que hace que los records sean cómodos en los logs y en el depurador:
Console.WriteLine(new Product("MUG-01", "Mug", 8.50m));
// Product { Sku = MUG-01, Name = Mug, Price = 8.50 }
Las colecciones se imprimen como el nombre de su tipo (System.Collections.Generic.List`1[System.String]), y los records anidados se imprimen de forma recursiva. Puedes sustituir toda la salida sobrescribiendo ToString:
public record Money(decimal Amount, string Currency)
{
public override string ToString() => $"{Amount:F2} {Currency}";
}
record struct (C# 10)
record a secas significa record class: un tipo de referencia. C# 10 añadió record struct, un tipo de valor con los mismos miembros generados:
public readonly record struct Point(int X, int Y);
var p = new Point(3, 4);
var q = p with { Y = 10 };
Console.WriteLine(p == new Point(3, 4)); // True
Console.WriteLine(q); // Point { X = 3, Y = 10 }
Conviene recordar la diferencia en los valores por defecto: un record struct posicional tiene propiedades mutables ({ get; set; }), en línea con el comportamiento habitual de los structs, mientras que readonly record struct y record class las tienen init-only. Elige entre ellos como elegirías entre un struct y una clase: los valores pequeños que se copian sin problema encajan con readonly record struct; todo lo demás, con record.
Herencia
Un record puede heredar de otro record (no de una clase, y una clase no puede heredar de un record). Los parámetros posicionales se pasan a la base como argumentos del constructor:
public abstract record Shape(string Color);
public record Circle(string Color, double Radius) : Shape(Color);
public record Square(string Color, double Side) : Shape(Color);
Shape a = new Circle("red", 2);
Shape b = new Circle("red", 2);
Shape c = new Square("red", 2);
Console.WriteLine(a == b); // True
Console.WriteLine(a == c); // False: different runtime types are never equal
Console.WriteLine(a); // Circle { Color = red, Radius = 2 }
La igualdad incluye el tipo en tiempo de ejecución, mediante una propiedad generada EqualityContract. Por eso un Circle nunca es igual a un Square con el mismo Color, aunque los dos se comparen a través de Shape, y por eso ToString y with funcionan sobre el tipo derivado aunque la variable sea del tipo base.
Lo mismo en C# 7: una clase con igualdad por valor
Los records generan código que puedes escribir tú, y verlo explica su comportamiento. Aquí tienes una clase de C# 7 equivalente a public record Point(int X, int Y);: propiedades de solo lectura, un constructor, Deconstruct, igualdad por valor, un código hash coherente, ==, ToString y un método With que hace el papel de la expresión with.
Salida:
True
False
Point { X = 3, Y = 10 }
Point { X = 3, Y = 4 }
x=3, y=10
True
False
Unas 30 líneas para dos propiedades, y cada propiedad nueva obliga a volver a tocar el constructor, Deconstruct, Equals, GetHashCode y ToString. Olvidar uno de ellos es un bug clásico (dos puntos que son == pero tienen hashes distintos, así que un HashSet les pierde la pista). Ese mantenimiento es lo que eliminan los records.
La clase es sealed a propósito: la igualdad por valor combinada con la herencia necesita la comprobación de tipo extra que los records generan mediante EqualityContract, y sellarla esquiva el problema.
Cuándo usar un record
Los records encajan con datos que se definen por sus valores y no cambian después de crearse:
- modelos de petición y respuesta de las API web;
- mensajes, comandos y eventos que se pasan entre partes de un sistema;
- objetos de configuración y de opciones;
- claves compuestas de diccionario (
record CacheKey(string Region, int Year)); - resultados de un cálculo (
record PriceQuote(decimal Net, decimal Tax)).
Encajan mal donde la identidad importa más que los valores: una entidad de Entity Framework es "el cliente 42" aunque cambie su nombre, y el seguimiento de cambios de EF Core depende de la identidad de referencia. Usa una clase ahí.
Errores comunes
- Esperar igualdad profunda en las colecciones. Una propiedad
List<T>se compara por referencia. Dos records con listas que parecen iguales no son iguales. - Esperar que
withhaga una copia profunda. Los objetos anidados y las colecciones se comparten entre el original y la copia. - Un
record structposicional mutable sin querer. Añadereadonlysalvo que quieras propiedades asignables. - Usar records como entidades de EF Core. La igualdad por valor y las copias chocan con el seguimiento de cambios.
- Añadir un record a un proyecto de C# 8. Los records necesitan C# 9 (el valor por defecto en .NET 5 y posteriores). En destinos más antiguos, escribe la clase a mano como se muestra arriba.
Preguntas frecuentes
¿Qué es un record en C#?
Un record (C# 9) es una clase, o con record struct (C# 10) un struct, para la que el compilador genera la igualdad por valor, un ToString() legible, un método Deconstruct y el soporte para copias con with. public record Product(string Sku, decimal Price); es un tipo completo con dos propiedades init-only. Dos records con los mismos valores en sus propiedades son iguales, aunque sean objetos distintos.
¿Qué diferencia hay entre un record y una clase en C#?
Un record es por debajo una clase, así que es un tipo de referencia y puede heredar de otros records. Las diferencias son los miembros generados: los records comparan por valor (== y Equals comprueban cada campo), imprimen sus propiedades desde ToString() y admiten with. Una clase normal compara por referencia e imprime el nombre de su tipo salvo que escribas tú esos miembros.
¿Qué hace la expresión with en C#?
var sale = product with { Price = 6.00m }; crea un record nuevo que copia todas las propiedades de product y después asigna las indicadas. El original no cambia. La copia es superficial: una propiedad List<T> la comparten los dos records, así que añadirle algo a través de uno se ve a través del otro.
¿Qué es un record struct en C#?
record struct (C# 10) es un tipo de valor con los mismos miembros generados que un record de clase: igualdad por valor, ToString, Deconstruct y with. A diferencia de un record de clase, sus propiedades posicionales son mutables por defecto; decláralo readonly record struct para hacerlas init-only. Úsalo para valores pequeños como coordenadas o cantidades de dinero.
¿Cuándo debo usar un record en C#?
Para datos cuya identidad son sus valores: DTO, modelos de petición y respuesta de API, mensajes y eventos, configuración y claves de diccionarios. Evita los records para entidades que cambian con el tiempo y se identifican por un id, como las entidades de Entity Framework, porque la igualdad por valor y las copias con with van en contra del seguimiento de cambios.