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

Record Types

Define value-based equality types with concise syntax and built-in features

Record Types in C#

Master record types in C# with free flashcards and spaced repetition practice. This lesson covers record declaration syntax, value-based equality semantics, and immutable data modelingβ€”essential concepts for writing modern, maintainable C# code.

Welcome to Record Types! πŸ’»

Welcome to one of the most exciting features introduced in C# 9.0 and enhanced in C# 10! Record types represent a paradigm shift in how we model data in C#. While classes have served us well for decades, records provide a more concise, expressive way to create immutable data structures with built-in value semantics.

Think of records as purpose-built types for scenarios where you need to represent data rather than behavior. They're perfect for DTOs (Data Transfer Objects), domain models in functional-style programming, and any situation where you want two objects with the same data to be considered equal.

Core Concepts: Understanding Records 🎯

What Are Record Types?

Record types are reference types that provide built-in functionality for encapsulating data with value-based equality. Unlike regular classes where two instances are equal only if they reference the same object in memory (reference equality), records are equal if all their property values match.

FeatureClassRecord
EqualityReference-basedValue-based
MutabilityMutable by defaultImmutable by design
SyntaxVerboseConcise
ToString()Shows type nameShows all properties
DeconstructionManual implementationBuilt-in support
Best ForBehavior-rich objectsData-centric models

Record Declaration Syntax

C# offers two syntaxes for declaring records:

1. Positional Syntax (Concise):

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

This single line creates a record with:

  • Three public properties (init-only by default)
  • A primary constructor
  • Deconstruction support
  • Value-based equality
  • A meaningful ToString() implementation

2. Standard Syntax (Detailed):

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

This syntax gives you more control over property behavior and allows you to add additional members easily.

πŸ’‘ Tip: Use positional syntax for simple data carriers, and standard syntax when you need to add methods, validation, or computed properties.

Value-Based Equality Semantics πŸ”

The killer feature of records is value equality. Let's see this in action:

var person1 = new Person("Alice", "Smith", 30);
var person2 = new Person("Alice", "Smith", 30);
var person3 = person1;

Console.WriteLine(person1 == person2);  // True! (value equality)
Console.WriteLine(person1 == person3);  // True (same reference)
Console.WriteLine(ReferenceEquals(person1, person2));  // False

With classes, person1 == person2 would be false because they're different objects in memory. Records compare the values of all properties instead.

Behind the scenes, the compiler generates:

  • Equals(object) method
  • Equals(Person) method (type-specific)
  • GetHashCode() method
  • == and != operator overloads

Immutability and the with Expression πŸ”’

Records encourage immutabilityβ€”once created, a record's properties cannot be changed. Properties declared in positional syntax are automatically init-only.

But what if you need to create a modified copy? Enter the with expression:

var person = new Person("Bob", "Jones", 25);
var olderPerson = person with { Age = 26 };

Console.WriteLine(person.Age);       // 25 (original unchanged)
Console.WriteLine(olderPerson.Age);  // 26 (new copy)
Console.WriteLine(person.FirstName == olderPerson.FirstName);  // True

The with expression creates a non-destructive mutationβ€”a new record with selected properties modified. The original remains unchanged.

🧠 Memory Device: Think "with" as "with these changes" β†’ creates a modified copy.

Record Structs (C# 10+) πŸ“¦

C# 10 introduced record structs, which combine record features with value type semantics:

public record struct Point(double X, double Y);
FeatureRecord ClassRecord Struct
MemoryHeap-allocatedStack-allocated
TypeReference typeValue type
Mutability DefaultImmutable (init)Mutable (set)
Best ForComplex data modelsSmall, simple data
NullCan be nullCannot be null

⚠️ Important: Record structs are mutable by default! Use readonly record struct for immutability:

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

Inheritance in Records 🌳

Records support inheritance, but only from other records:

public record Person(string FirstName, string LastName);
public record Employee(string FirstName, string LastName, string EmployeeId) 
    : Person(FirstName, LastName);

Value equality works correctly across inheritance hierarchies:

Person person = new Employee("Alice", "Smith", "E123");
Person person2 = new Employee("Alice", "Smith", "E123");

Console.WriteLine(person == person2);  // True!

⚠️ Note: Record structs cannot inherit or be inherited from.

Practical Examples πŸ”§

Example 1: Domain Modeling

Records excel at modeling immutable domain entities:

public record Money(decimal Amount, string Currency)
{
    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException("Cannot add different currencies");
        
        return this with { Amount = Amount + other.Amount };
    }
}

// Usage
var price1 = new Money(19.99m, "USD");
var price2 = new Money(5.00m, "USD");
var total = price1.Add(price2);

Console.WriteLine(total);  // Money { Amount = 24.99, Currency = USD }

Notice how Add doesn't modify the original objectsβ€”it returns a new Money instance. This immutability makes your code predictable and thread-safe.

Example 2: DTOs for APIs 🌐

Records are perfect for Data Transfer Objects:

public record CreateUserRequest(string Username, string Email, string Password);

public record UserResponse(int Id, string Username, string Email, DateTime CreatedAt);

public record ApiResult<T>(bool Success, T? Data, string? Error)
{
    public static ApiResult<T> Ok(T data) => new(true, data, null);
    public static ApiResult<T> Fail(string error) => new(false, default, error);
}

// Usage
public async Task<ApiResult<UserResponse>> CreateUser(CreateUserRequest request)
{
    try
    {
        var user = await _userService.CreateAsync(request);
        var response = new UserResponse(user.Id, user.Username, user.Email, user.CreatedAt);
        return ApiResult<UserResponse>.Ok(response);
    }
    catch (Exception ex)
    {
        return ApiResult<UserResponse>.Fail(ex.Message);
    }
}

Example 3: Configuration Objects βš™οΈ

Records provide clean, immutable configuration:

public record DatabaseConfig(
    string ConnectionString,
    int MaxRetries = 3,
    int TimeoutSeconds = 30
)
{
    public DatabaseConfig WithConnectionString(string newConnectionString) 
        => this with { ConnectionString = newConnectionString };
}

public record AppSettings(
    DatabaseConfig Database,
    string ApiKey,
    bool EnableLogging = true
);

// Usage
var devConfig = new AppSettings(
    Database: new DatabaseConfig("Server=localhost;Database=DevDB"),
    ApiKey: "dev-key-123"
);

var prodConfig = devConfig with
{
    Database = devConfig.Database with 
    { 
        ConnectionString = "Server=prod.example.com;Database=ProdDB",
        TimeoutSeconds = 60
    },
    ApiKey = "prod-key-456"
};

Example 4: Pattern Matching 🎭

Records work beautifully with C#'s pattern matching:

public record Shape;
public record Circle(double Radius) : Shape;
public record Rectangle(double Width, double Height) : Shape;
public record Triangle(double Base, double Height) : Shape;

public static double CalculateArea(Shape shape) => shape switch
{
    Circle { Radius: var r } => Math.PI * r * r,
    Rectangle { Width: var w, Height: var h } => w * h,
    Triangle { Base: var b, Height: var h } => 0.5 * b * h,
    _ => throw new ArgumentException("Unknown shape")
};

// Usage with deconstruction
public static string Describe(Shape shape) => shape switch
{
    Circle(var r) when r > 10 => "Large circle",
    Circle(var r) => $"Small circle (r={r})",
    Rectangle(var w, var h) when w == h => "Square",
    Rectangle(var w, var h) => $"Rectangle {w}x{h}",
    _ => "Other shape"
};

Common Mistakes to Avoid ⚠️

Mistake 1: Confusing Records with Structs

❌ Wrong Assumption:

// "Records are value types, right?"
record Person(string Name);  // Actually a reference type!

void PassByValue(Person p)
{
    p = new Person("Changed");  // Doesn't affect original
}

βœ… Correct Understanding:

// Records are reference types with value semantics for equality
record Person(string Name);

var p1 = new Person("Alice");
var p2 = p1;  // Both point to same object
p2 = p2 with { Name = "Bob" };  // Now p2 is a new object

Console.WriteLine(p1.Name);  // Still "Alice"
Console.WriteLine(p2.Name);  // "Bob"

Mistake 2: Mixing Mutability Expectations

❌ Wrong:

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

var person = new Person("Alice", new List<string> { "Reading" });
person.Hobbies.Add("Gaming");  // Mutates the list!

// Record is "immutable" but list isn't!

βœ… Correct:

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

// Or use immutable collections
using System.Collections.Immutable;

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

var person = new Person("Alice", ImmutableList.Create("Reading"));
var updated = person with 
{ 
    Hobbies = person.Hobbies.Add("Gaming")  // Returns new list
};

Mistake 3: Forgetting Record Structs Are Mutable

❌ Wrong:

public record struct Temperature(double Celsius);

var temp = new Temperature(25.0);
temp.Celsius = 30.0;  // This works! Record struct is mutable by default

βœ… Correct:

public readonly record struct Temperature(double Celsius);

var temp = new Temperature(25.0);
// temp.Celsius = 30.0;  // Compilation error
var newTemp = temp with { Celsius = 30.0 };  // Must use 'with'

Mistake 4: Overcomplicating Simple Records

❌ Wrong:

public record Person
{
    private string _firstName;
    private string _lastName;
    
    public string FirstName 
    { 
        get => _firstName; 
        init => _firstName = value ?? throw new ArgumentNullException();
    }
    
    public string LastName 
    { 
        get => _lastName;
        init => _lastName = value ?? throw new ArgumentNullException();
    }
}

βœ… Correct:

public record Person(string FirstName, string LastName)
{
    // Add validation in constructor if needed
    public Person(string FirstName, string LastName) : this(
        FirstName ?? throw new ArgumentNullException(nameof(FirstName)),
        LastName ?? throw new ArgumentNullException(nameof(LastName)))
    {
    }
}

// Or use required properties (C# 11+)
public record Person
{
    public required string FirstName { get; init; }
    public required string LastName { get; init; }
}

When to Use Records vs. Classes πŸ€”

Use Records When:

  • βœ… You're modeling data, not behavior
  • βœ… You want value-based equality
  • βœ… You need immutability
  • βœ… You're creating DTOs or API models
  • βœ… You want concise syntax
  • βœ… You're doing functional-style programming

Use Classes When:

  • βœ… You have behavior-rich objects
  • βœ… You need reference equality
  • βœ… You require mutability
  • βœ… You're working with Entity Framework entities (though records work here too)
  • βœ… You need fine-grained control over equality
DECISION TREE: Record or Class?

    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚ Is it primarily data?   β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                 β”‚
      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
      β”‚                     β”‚
   β”Œβ”€β”€β”΄β”€β”€β”               β”Œβ”€β”€β”΄β”€β”€β”
   β”‚ YES β”‚               β”‚ NO  β”‚
   β””β”€β”€β”¬β”€β”€β”˜               β””β”€β”€β”¬β”€β”€β”˜
      β”‚                     β”‚
      β–Ό                     β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Need value   β”‚      β”‚ Use CLASS    β”‚
β”‚ equality?    β”‚      β”‚              β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜      β”‚ Behavior-    β”‚
       β”‚              β”‚ focused      β”‚
    β”Œβ”€β”€β”΄β”€β”€β”           β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
    β”‚ YES β”‚
    β””β”€β”€β”¬β”€β”€β”˜
       β”‚
       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Use RECORD   β”‚
β”‚              β”‚
β”‚ - Concise    β”‚
β”‚ - Immutable  β”‚
β”‚ - Value =    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Real-World Scenario: Event Sourcing 🎬

Records shine in event sourcing architectures:

public abstract record DomainEvent(Guid AggregateId, DateTime OccurredAt);

public record UserRegistered(
    Guid AggregateId,
    DateTime OccurredAt,
    string Username,
    string Email
) : DomainEvent(AggregateId, OccurredAt);

public record EmailVerified(
    Guid AggregateId,
    DateTime OccurredAt
) : DomainEvent(AggregateId, OccurredAt);

public record ProfileUpdated(
    Guid AggregateId,
    DateTime OccurredAt,
    string? NewName,
    string? NewBio
) : DomainEvent(AggregateId, OccurredAt);

public class UserAggregate
{
    private readonly List<DomainEvent> _events = new();
    
    public Guid Id { get; private set; }
    public string Username { get; private set; } = "";
    public string Email { get; private set; } = "";
    public bool IsEmailVerified { get; private set; }
    
    public void Apply(DomainEvent @event)
    {
        switch (@event)
        {
            case UserRegistered e:
                Id = e.AggregateId;
                Username = e.Username;
                Email = e.Email;
                break;
            
            case EmailVerified:
                IsEmailVerified = true;
                break;
            
            case ProfileUpdated e:
                // Update profile fields
                break;
        }
        
        _events.Add(@event);
    }
}

Each event is immutable, has value equality, and contains all necessary dataβ€”perfect use case for records!

Key Takeaways πŸŽ“

  1. Records are reference types with value-based equality semanticsβ€”they compare by property values, not memory addresses

  2. Positional syntax (record Person(string Name)) provides ultra-concise declaration for simple data carriers

  3. Immutability is built-in with init accessors, promoting thread-safe, predictable code

  4. The with expression enables non-destructive mutationβ€”creating modified copies while preserving originals

  5. Record structs combine record features with value type semantics but are mutable by default (use readonly record struct)

  6. Inheritance works but only between records, not between records and classes

  7. Use records for data, classes for behaviorβ€”records excel at DTOs, domain models, and configuration objects

  8. Watch for mutable nested objectsβ€”record immutability doesn't extend to mutable properties like List<T>

πŸ“‹ Quick Reference Card

Declarationrecord Person(string Name);
With Expressionvar p2 = p1 with { Name = "New" };
Record Structreadonly record struct Point(double X, double Y);
Inheritancerecord Employee(...) : Person(...);
Deconstructionvar (name, age) = person;
EqualityValue-based (compares all properties)
ToString()Shows type name and all property values

πŸ“š Further Study

  1. Microsoft Docs - Records: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record
  2. C# 10 Record Structs: https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-10#record-structs
  3. Immutable Collections Guide: https://learn.microsoft.com/en-us/dotnet/api/system.collections.immutable

πŸ’‘ Next Steps: Practice by refactoring existing DTOs and data classes in your projects to records. Notice how much boilerplate code disappears while gaining value equality and immutability benefits!