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.

C# Example Code
// 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=2

Standard Record Syntax

When you need additional methods, computed properties, or validation, use the body form.

C# Example Code
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.

C# Example Code
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.

C# Example Code
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 compared

Mutable Records

Records are immutable by default, but you can declare set (not init) properties when you need mutability.

C# Example Code
// 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; // allowed

record 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.

C# Example Code
// 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

classrecordrecord struct
TypeReferenceReferenceValue
EqualityReference (by default)Value (all properties)Value (all properties)
Immutable by defaultNoYes (init props)No (mutable by default)
with expressionNoYesYes
ToString()Type name onlyAll properties listedAll properties listed
InheritanceYesYesNo
AllocationHeapHeapStack
Best forStateful objects, servicesDTOs, value objects, eventsSmall value types

Real-World Example: API Response DTO

C# Example Code
// 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