C# record types — record vs class, with expressions, and immutability
Records (C# 9+) are a special kind of reference type designed for immutable data models. The compiler automatically generates value-based equality (Equals, GetHashCode, ==), a ToString() that lists all properties, and Deconstruct — things you'd write by hand for a plain class.
Use records for DTOs, value objects, domain events, and any type whose identity is defined by its data rather than its reference.
Defining a Record — Positional Syntax
The shortest form uses a positional parameter list. The compiler generates a primary constructor, init-only properties, and all equality infrastructure automatically.
// Positional record — one line
public record Point(double X, double Y);
var p1 = new Point(1.0, 2.0);
var p2 = new Point(1.0, 2.0);
var p3 = new Point(3.0, 4.0);
Console.WriteLine(p1 == p2); // True ← value equality (not reference)
Console.WriteLine(p1 == p3); // False
Console.WriteLine(p1); // Point { X = 1, Y = 2 }
// Deconstruct
var (x, y) = p1;
Console.WriteLine($"x={x}, y={y}"); // x=1, y=2Standard Record Syntax
When you need additional methods, computed properties, or validation, use the body form.
public record Person(string FirstName, string LastName)
{
// Computed property
public string FullName => $"{FirstName} {LastName}";
// Custom validation in the constructor
public Person : this(FirstName, LastName)
{
if (string.IsNullOrWhiteSpace(FirstName))
throw new ArgumentException("First name cannot be blank.");
}
}
var alice = new Person("Alice", "Smith");
Console.WriteLine(alice.FullName); // Alice Smith
Console.WriteLine(alice); // Person { FirstName = Alice, LastName = Smith }with Expressions — Non-Destructive Mutation
Because records are immutable, you can't change a property. Instead, use a with expression to create a copy with selected properties changed.
public record Address(string Street, string City, string Country);
var original = new Address("10 Downing St", "London", "UK");
// Create a copy with only City changed
var updated = original with { City = "Manchester" };
Console.WriteLine(original); // Address { Street = 10 Downing St, City = London, Country = UK }
Console.WriteLine(updated); // Address { Street = 10 Downing St, City = Manchester, Country = UK }
// Chain multiple changes
var moved = original with { City = "Edinburgh", Country = "Scotland" };
Console.WriteLine(moved); // Address { Street = 10 Downing St, City = Edinburgh, Country = Scotland }Record Inheritance
Records support inheritance. A derived record extends the base with additional properties.
public record Shape(string Color);
public record Circle(string Color, double Radius) : Shape(Color);
public record Rectangle(string Color, double Width, double Height) : Shape(Color);
Shape c = new Circle("Red", 5.0);
Shape r = new Rectangle("Blue", 4.0, 6.0);
Console.WriteLine(c); // Circle { Color = Red, Radius = 5 }
Console.WriteLine(r); // Rectangle { Color = Blue, Width = 4, Height = 6 }
// Equality is type-aware — different derived types are never equal
var c1 = new Circle("Red", 5.0);
var c2 = new Circle("Red", 5.0);
Console.WriteLine(c1 == c2); // True
Shape s1 = new Circle("Red", 5.0);
Shape s2 = new Circle("Red", 5.0);
Console.WriteLine(s1 == s2); // True — runtime type comparedMutable Records
Records are immutable by default, but you can declare set (not init) properties when you need mutability.
// Mutable record — use sparingly; defeats the immutability benefit
public record MutablePoint(double X, double Y)
{
public double X { get; set; } = X;
public double Y { get; set; } = Y;
}
var p = new MutablePoint(1.0, 2.0);
p.X = 99.0; // allowedrecord struct (C# 10+)
For small, value-semantics types that should live on the stack, use record struct. It combines struct allocation with the record convenience syntax.
// record struct — stack allocated, value equality
public record struct Coordinate(double Latitude, double Longitude);
var loc1 = new Coordinate(51.5, -0.1);
var loc2 = new Coordinate(51.5, -0.1);
Console.WriteLine(loc1 == loc2); // True
Console.WriteLine(loc1); // Coordinate { Latitude = 51.5, Longitude = -0.1 }
// with expression works on record struct too
var shifted = loc1 with { Longitude = 0.0 };Record vs Class — Quick Comparison
class | record | record struct | |
|---|---|---|---|
| Type | Reference | Reference | Value |
| Equality | Reference (by default) | Value (all properties) | Value (all properties) |
| Immutable by default | No | Yes (init props) | No (mutable by default) |
with expression | No | Yes | Yes |
ToString() | Type name only | All properties listed | All properties listed |
| Inheritance | Yes | Yes | No |
| Allocation | Heap | Heap | Stack |
| Best for | Stateful objects, services | DTOs, value objects, events | Small value types |
Real-World Example: API Response DTO
// Immutable response record — safe to share across threads, easy to test
public record UserDto(
int Id,
string Email,
string DisplayName,
bool IsActive
);
// Simulate deserializing from an API response
var user = new UserDto(1, "alice@example.com", "Alice", true);
// Produce a "deactivated" copy without touching the original
var deactivated = user with { IsActive = false };
Console.WriteLine(user.IsActive); // True
Console.WriteLine(deactivated.IsActive); // False
// Records work great in collections
var users = new List<UserDto>
{
new(1, "alice@example.com", "Alice", true),
new(2, "bob@example.com", "Bob", false)
};
var activeUsers = users.Where(u => u.IsActive).ToList();
Console.WriteLine(activeUsers.Count); // 1