You are viewing a preview of this lesson. Sign in to start learning
Back to C# Programming

Records & Immutability

Build immutable data models with records, primary constructors, and with-expressions

Records & Immutability in C#

Master records and immutability in C# with free flashcards and spaced repetition practice. This lesson covers record types, immutable patterns, with-expressions, and value-based equalityβ€”essential concepts for writing robust, thread-safe modern C# applications.

Welcome πŸ’»

Welcome to the world of records and immutability in C#! If you've ever struggled with unexpected data changes, race conditions in multi-threaded code, or complex equality comparisons, records are here to revolutionize your approach. Introduced in C# 9.0 and enhanced in C# 10.0, records provide a concise syntax for creating immutable reference types that prioritize value-based equality over reference equality.

Immutability isn't just a buzzwordβ€”it's a fundamental principle that makes your code more predictable, easier to reason about, and naturally thread-safe. When data can't change after creation, entire categories of bugs simply disappear. Records make immutability not just possible, but elegant and practical.

Core Concepts 🎯

What Are Records?

Records are reference types designed specifically for immutable data modeling. Unlike traditional classes that use reference equality (two variables are equal only if they point to the same object), records use value-based equality (two variables are equal if their property values match).

Think of records like photographs of a moment in timeβ€”once captured, they don't change. If you want a different photo, you create a new one based on the original.

FeatureClassRecordStruct
TypeReferenceReference (record class) or Value (record struct)Value
EqualityReference-basedValue-basedValue-based
MutabilityMutable by defaultImmutable by defaultMutable by default
InheritanceSupportedSupported (record class only)Not supported
Use CaseComplex objects, behavior-richData transfer, immutable stateSmall, simple values

Record Declaration Syntax

C# offers multiple ways to declare records, from ultra-concise to explicit:

Positional Records (C# 9.0+):

public record Person(string FirstName, string LastName, int Age);

This single line generates:

  • Public init-only properties
  • A primary constructor
  • Deconstruction support
  • Value-based equality
  • ToString() override
  • GetHashCode() override

Traditional Record Syntax:

public record Person
{
    public string FirstName { get; init; }
    public string LastName { get; init; }
    public int Age { get; init; }
}

Record Structs (C# 10.0+):

public record struct Point(double X, double Y);

πŸ’‘ Memory Tip: "Positional Records = Powerful Rapidity" - one line does it all!

Init-Only Properties

The init accessor is the secret sauce of immutability. Unlike set (which allows modification anytime) or a constructor-only approach (which lacks flexibility), init allows property assignment during object initialization but nowhere else:

public record Book
{
    public string Title { get; init; }
    public string Author { get; init; }
    public int Pages { get; init; }
}

// Valid - during initialization
var book = new Book 
{ 
    Title = "1984", 
    Author = "George Orwell", 
    Pages = 328 
};

// Compile error - after initialization
book.Pages = 350; // ❌ Error: init-only property

With-Expressions: Non-Destructive Mutation

The with keyword provides non-destructive mutationβ€”creating a modified copy while leaving the original unchanged. This is the idiomatic way to "change" immutable records:

var person1 = new Person("Alice", "Smith", 30);
var person2 = person1 with { Age = 31 };

// person1 is unchanged: Alice Smith, 30
// person2 is a new object: Alice Smith, 31

🌍 Real-World Analogy: Think of with-expressions like editing a photo. You don't modify the original fileβ€”you create a new version with adjustments applied. The original remains pristine in case you need it.

With-Expression Flow:

  Original Record          With-Expression           New Record
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ FirstName: Aliceβ”‚                          β”‚ FirstName: Aliceβ”‚
β”‚ LastName: Smith β”‚  ───→ with { Age=31 }──→ β”‚ LastName: Smith β”‚
β”‚ Age: 30         β”‚                          β”‚ Age: 31         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       ↑                                            ↑
       β”‚                                            β”‚
   Unchanged                                  New instance

Value-Based Equality

Records automatically implement value-based equality, comparing all properties rather than references:

var person1 = new Person("Bob", "Jones", 25);
var person2 = new Person("Bob", "Jones", 25);
var person3 = person1; // Same reference

// Value-based equality
Console.WriteLine(person1 == person2);  // True - same values
Console.WriteLine(person1.Equals(person2)); // True

// Reference equality still available
Console.WriteLine(ReferenceEquals(person1, person2)); // False
Console.WriteLine(ReferenceEquals(person1, person3)); // True

For classes, the comparison would be:

var class1 = new PersonClass("Bob", "Jones", 25);
var class2 = new PersonClass("Bob", "Jones", 25);

Console.WriteLine(class1 == class2); // False - different references!

Deconstruction

Positional records support deconstruction, allowing you to extract properties into separate variables:

public record Person(string FirstName, string LastName, int Age);

var person = new Person("Carol", "White", 28);

// Deconstruction
var (firstName, lastName, age) = person;
Console.WriteLine($"{firstName} is {age}"); // Carol is 28

// Partial deconstruction with discard
var (first, _, personAge) = person; // Ignore LastName

Record Inheritance

Record classes (not record structs) support inheritance, with some special rules:

public record Person(string FirstName, string LastName);
public record Student(string FirstName, string LastName, string StudentId) 
    : Person(FirstName, LastName);

var student = new Student("David", "Brown", "S12345");
Console.WriteLine(student); // Student { FirstName = David, LastName = Brown, StudentId = S12345 }

⚠️ Important Rules:

  • Records can only inherit from other records
  • Record structs cannot use inheritance
  • Sealed records cannot be inherited from
  • Equality comparisons respect the runtime type

Immutability Benefits

Why embrace immutability? The benefits are substantial:

πŸ”’ Immutability Advantages

Thread SafetyNo synchronization neededβ€”immutable objects are inherently thread-safe
PredictabilityData can't change unexpectedly; easier to reason about code flow
Hash SafetySafe to use as dictionary keysβ€”hash code never changes
CachingResults can be cached without worrying about stale data
DebuggingValues at a point in time are preserved; easier to trace bugs
Functional StyleEnables functional programming patterns and LINQ transformations

πŸ€” Did You Know? Functional programming languages like F# and Haskell have used immutability as a core principle for decades. C# records bring these battle-tested concepts to the .NET ecosystem, making your code more reliable without sacrificing C#'s familiar syntax.

Detailed Examples πŸ”

Example 1: Building a Data Transfer Object (DTO)

Records excel at creating DTOs for APIs and data transfer:

// API response model
public record WeatherForecast(
    DateTime Date,
    int TemperatureC,
    string Summary
)
{
    // Computed property
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
    
    // Validation method
    public bool IsExtreme() => TemperatureC < -20 || TemperatureC > 45;
}

// Usage
var forecast = new WeatherForecast(
    Date: DateTime.Today,
    TemperatureC: 25,
    Summary: "Sunny"
);

Console.WriteLine(forecast); 
// WeatherForecast { Date = 12/15/2024, TemperatureC = 25, Summary = Sunny, TemperatureF = 77 }

// Create tomorrow's forecast based on today's
var tomorrow = forecast with 
{ 
    Date = DateTime.Today.AddDays(1), 
    TemperatureC = 27 
};

Why this works beautifully:

  • Concise syntax reduces boilerplate
  • Automatic ToString() provides readable output for logging
  • Value equality makes testing straightforward
  • With-expressions make it easy to create variations

Example 2: Immutable State Management

Records are perfect for representing application state in modern architectures:

public record AppState(
    bool IsLoading,
    string? ErrorMessage,
    ImmutableList<string> Items
)
{
    // Factory method for initial state
    public static AppState Initial() => new(
        IsLoading: false,
        ErrorMessage: null,
        Items: ImmutableList<string>.Empty
    );
}

// State transitions are explicit and traceable
var state1 = AppState.Initial();

var state2 = state1 with { IsLoading = true };
// Start loading...

var state3 = state2 with 
{ 
    IsLoading = false, 
    Items = state2.Items.Add("New Item") 
};
// Loading complete, item added

var state4 = state3 with { ErrorMessage = "Connection failed" };
// Error occurred

// Each state is preservedβ€”perfect for debugging!
Console.WriteLine($"State 1 loading: {state1.IsLoading}"); // False
Console.WriteLine($"State 2 loading: {state2.IsLoading}"); // True
Console.WriteLine($"State 3 items: {state3.Items.Count}"); // 1

πŸ’‘ Pro Tip: Using records for state management creates an audit trail. You can implement "time travel debugging" by storing each state transition, allowing you to replay exactly what happened leading up to a bug.

Example 3: Record Structs for High-Performance Scenarios

When you need value semantics with value type performance, use record structs:

public record struct Point3D(double X, double Y, double Z)
{
    // Computed properties
    public double DistanceFromOrigin => Math.Sqrt(X * X + Y * Y + Z * Z);
    
    // Methods
    public Point3D Translate(double dx, double dy, double dz) =>
        this with { X = X + dx, Y = Y + dy, Z = Z + dz };
    
    public static Point3D operator +(Point3D a, Point3D b) =>
        new(a.X + b.X, a.Y + b.Y, a.Z + b.Z);
}

// Usage
var point1 = new Point3D(1.0, 2.0, 3.0);
var point2 = point1.Translate(1.0, 0.0, 0.0);
var point3 = point1 + point2;

Console.WriteLine($"Distance: {point3.DistanceFromOrigin:F2}"); // Distance: 5.20

// Value equality works automatically
var point4 = new Point3D(3.0, 4.0, 6.0);
Console.WriteLine(point3 == point4); // True - same values!

Performance Note: Record structs avoid heap allocation, making them ideal for:

  • Mathematical computations with many small objects
  • Game development (positions, vectors, colors)
  • Financial calculations (money amounts, rates)
  • High-throughput data processing

Example 4: Complex Domain Models with Validation

Records can include validation logic while maintaining immutability:

public record Email
{
    public string Address { get; init; }
    
    public Email(string address)
    {
        if (string.IsNullOrWhiteSpace(address))
            throw new ArgumentException("Email cannot be empty", nameof(address));
        
        if (!address.Contains('@'))
            throw new ArgumentException("Invalid email format", nameof(address));
        
        Address = address.ToLowerInvariant();
    }
    
    // Deconstruction support
    public void Deconstruct(out string address) => address = Address;
}

public record User(
    string Username,
    Email Email,
    DateTime RegisteredAt
)
{
    // Additional validation in constructor
    public User(string username, Email email, DateTime registeredAt) : this(
        username ?? throw new ArgumentNullException(nameof(username)),
        email ?? throw new ArgumentNullException(nameof(email)),
        registeredAt)
    {
        if (username.Length < 3)
            throw new ArgumentException("Username too short", nameof(username));
        
        if (registeredAt > DateTime.UtcNow)
            throw new ArgumentException("Registration date cannot be in future");
    }
}

// Usage
try
{
    var email = new Email("user@example.com");
    var user = new User(
        Username: "john_doe",
        Email: email,
        RegisteredAt: DateTime.UtcNow.AddDays(-30)
    );
    
    // Valid user created
    Console.WriteLine(user);
    
    // This will throw
    var invalidUser = new User("ab", email, DateTime.UtcNow);
}
catch (ArgumentException ex)
{
    Console.WriteLine($"Validation failed: {ex.Message}");
}

Key Takeaway: Validation in constructors ensures that invalid records simply cannot exist. Once a record is created, you know it's validβ€”no defensive checks needed throughout your codebase.

Common Mistakes ⚠️

Mistake 1: Confusing Records with Classes for Behavior-Rich Objects

❌ Wrong Approach:

// Don't use records for objects with complex behavior
public record ShoppingCart(List<Item> Items)
{
    public void AddItem(Item item) => Items.Add(item); // Mutating!
    public void RemoveItem(Item item) => Items.Remove(item);
    public decimal CalculateTotal() => Items.Sum(i => i.Price);
    // ... 20 more methods
}

βœ… Correct Approach:

// Use classes for behavior-rich objects
public class ShoppingCart
{
    private readonly List<Item> _items = new();
    
    public IReadOnlyList<Item> Items => _items.AsReadOnly();
    
    public void AddItem(Item item) => _items.Add(item);
    public void RemoveItem(Item item) => _items.Remove(item);
    public decimal CalculateTotal() => _items.Sum(i => i.Price);
}

// Use records for the immutable data
public record Item(string Name, decimal Price, int Quantity);

Rule of Thumb: If an object has more than 5 methods or complex lifecycle management, use a class. Records are for data, classes are for behavior.

Mistake 2: Mutable Collections in Records

❌ Wrong Approach:

public record Team(string Name, List<string> Members);

var team = new Team("Alpha", new List<string> { "Alice", "Bob" });
team.Members.Add("Charlie"); // Record is "immutable" but list is not!

βœ… Correct Approach:

public record Team(string Name, ImmutableList<string> Members);

var team = new Team("Alpha", ImmutableList.Create("Alice", "Bob"));
var newTeam = team with { Members = team.Members.Add("Charlie") };

// Or use readonly collections
public record Team
{
    public string Name { get; init; }
    public IReadOnlyList<string> Members { get; init; }
    
    public Team(string name, IEnumerable<string> members)
    {
        Name = name;
        Members = members.ToList().AsReadOnly();
    }
}

Mistake 3: Using Records as Dictionary Keys Without Understanding Equality

❌ Potential Problem:

public record Person(string Name, List<string> Hobbies);

var dict = new Dictionary<Person, int>();
var person1 = new Person("Alice", new List<string> { "Reading" });
dict[person1] = 100;

person1.Hobbies.Add("Gaming"); // Mutated after adding to dictionary!
// Hash code changed, but dictionary doesn't knowβ€”lookup will fail!

βœ… Correct Approach:

public record Person(string Name, ImmutableList<string> Hobbies);

var dict = new Dictionary<Person, int>();
var person1 = new Person("Alice", ImmutableList.Create("Reading"));
dict[person1] = 100;

// To "modify", create new instance
var person2 = person1 with { Hobbies = person1.Hobbies.Add("Gaming") };
dict[person2] = 100; // New key, works perfectly

Mistake 4: Over-Using With-Expressions for Many Changes

❌ Inefficient:

var person = new Person("Alice", "Smith", 30, "alice@email.com", "555-1234");
var updated = person with { FirstName = "Alicia" };
updated = updated with { Age = 31 };
updated = updated with { Email = "alicia@email.com" };
updated = updated with { Phone = "555-5678" };
// Creates 4 intermediate objects!

βœ… Efficient:

var person = new Person("Alice", "Smith", 30, "alice@email.com", "555-1234");
var updated = person with 
{ 
    FirstName = "Alicia", 
    Age = 31, 
    Email = "alicia@email.com", 
    Phone = "555-5678" 
};
// Single object creation

Key Takeaways πŸŽ“

πŸ“‹ Quick Reference Card: Records & Immutability

ConceptSyntaxUse When
Positional Recordrecord Person(string Name, int Age);Simple DTOs, API models
Init Propertiespublic string Name { get; init; }Properties set during initialization only
With-Expressionvar p2 = p1 with { Age = 31 };Creating modified copies
Record Structrecord struct Point(int X, int Y);Small values, high performance needed
Deconstructionvar (name, age) = person;Extracting multiple values
Value Equalityperson1 == person2Automatic in records, compares values
Immutable CollectionsImmutableList<T>Collections in records

🧠 Memory Device: "RICE"

  • Reference type (by default)
  • Immutable properties (init)
  • Concise syntax (positional)
  • Equality based on values

⚑ Quick Decision Guide

Need data modeling?
    ↓
Complex behavior/methods? ───YES──→ Use Class
    ↓ NO
Need inheritance? ───YES──→ Use Record Class
    ↓ NO
Need value type? ───YES──→ Use Record Struct
    ↓ NO
Use Record Class

Essential Principles:

  1. Records are for data, classes are for behavior: If your type has more methods than properties, use a class.

  2. Immutability prevents entire bug categories: Thread-safety issues, unexpected mutations, and hash code problems disappear.

  3. With-expressions are your modification tool: Never try to mutateβ€”always create new versions.

  4. Use immutable collections: List<T> in a record defeats the purpose. Use ImmutableList<T> or IReadOnlyList<T>.

  5. Validation in constructors: Ensure invalid records cannot exist rather than validating throughout your code.

  6. Value equality is powerful: Testing becomes simpler, and comparison logic is automatic.

  7. Record structs for performance: When you need value semantics without heap allocation.

πŸ“š Further Study


🎯 Practice Challenge: Try converting one of your existing classes that primarily holds data into a record. Notice how much boilerplate code disappears and how equality comparisons become simpler. Then experiment with with-expressions to see how natural non-destructive mutation feels!

Records represent a significant evolution in C#'s type system, bringing functional programming benefits to the object-oriented world. Master them, and your code will become more maintainable, testable, and robust. Happy coding! πŸ’»βœ¨