Skip to main content

C# Records

Introduction

Records were introduced in C# 9.0 as a new reference type that provides a simpler way to define immutable data models. If you've ever found yourself creating classes just to store data with lots of boilerplate code for constructors, property getters/setters, and equality comparison methods, records will make your life much easier.

Records are designed primarily for storing data, and they come with built-in functionality that makes them perfect for representing immutable data structures. They're especially useful in scenarios where you need to work with unchangeable data, like DTOs (Data Transfer Objects), domain objects, or messages in event-driven systems.

Why Use Records?

Before diving into how records work, let's understand why they were added to C#:

  • Conciseness: Records require significantly less code than equivalent classes
  • Immutability: They encourage immutable design by default
  • Value semantics: Records compare by value rather than by reference
  • Built-in functionality: They automatically include useful methods like ToString(), GetHashCode(), and equality operations

Basic Record Syntax

Let's start with the simplest way to define a record:

csharp
// Basic record definition
public record Person(string FirstName, string LastName, int Age);

This simple line gives you:

  • A constructor with three parameters
  • Three read-only properties
  • Value-based equality comparisons
  • A nice ToString() implementation
  • Deconstruction support

Using this record is straightforward:

csharp
// Creating a record instance
var person = new Person("John", "Doe", 30);

// Accessing properties
Console.WriteLine($"Name: {person.FirstName} {person.LastName}, Age: {person.Age}");

// Output:
// Name: John Doe, Age: 30

Value Equality in Records

One of the most important features of records is that they compare by value rather than by reference. This means two record instances with the same property values are considered equal.

csharp
var person1 = new Person("Jane", "Doe", 25);
var person2 = new Person("Jane", "Doe", 25);

Console.WriteLine($"Are equal? {person1 == person2}");
Console.WriteLine($"Same reference? {ReferenceEquals(person1, person2)}");

// Output:
// Are equal? True
// Same reference? False

This behavior differs from classes, where instances are only equal if they reference the same object.

Nondestructive Mutation with Records

Records are immutable by default, but C# provides a convenient way to create a new record from an existing one with some properties changed. This is done using the with expression:

csharp
var person = new Person("John", "Doe", 30);

// Create new record with one property changed
var olderPerson = person with { Age = 31 };

Console.WriteLine($"Original: {person}");
Console.WriteLine($"Modified: {olderPerson}");

// Output:
// Original: Person { FirstName = John, LastName = Doe, Age = 30 }
// Modified: Person { FirstName = John, LastName = Doe, Age = 31 }

The original record remains unchanged, and a new record is created with the modified values.

Record Types: Positional vs. Nominal

There are two main ways to define records in C#: positional records (what we've seen so far) and nominal records. Let's look at nominal records:

csharp
// Nominal record syntax
public record Employee
{
public string FirstName { get; init; }
public string LastName { get; init; }
public string Department { get; init; }
public decimal Salary { get; init; }
}

The key differences:

  • Nominal records use standard property syntax
  • Properties use the init keyword to make them set-only after initialization
  • You must manually create constructors if needed

Here's how to use a nominal record:

csharp
var employee = new Employee { 
FirstName = "Alice",
LastName = "Smith",
Department = "Engineering",
Salary = 85000
};

Inheritance with Records

Records support inheritance, allowing you to create more specialized records:

csharp
// Base record
public record Person(string FirstName, string LastName);

// Derived record
public record Student(string FirstName, string LastName, string Major, double GPA)
: Person(FirstName, LastName);

Using inheritance:

csharp
var student = new Student("Emma", "Johnson", "Computer Science", 3.8);
Console.WriteLine(student);

// Output:
// Student { FirstName = Emma, LastName = Johnson, Major = Computer Science, GPA = 3.8 }

Real-World Example: API Response Models

Records are perfect for API response models since these are typically immutable data structures:

csharp
// API response models as records
public record ApiResponse<T>(bool Success, T Data, string Message);
public record UserData(int Id, string Username, string Email, DateTime JoinDate);

// Using the records
public ApiResponse<UserData> GetUserById(int id)
{
// Simulate API call
if (id == 100)
{
var user = new UserData(
100,
"johndoe",
"[email protected]",
new DateTime(2022, 1, 15)
);

return new ApiResponse<UserData>(true, user, "User found");
}

return new ApiResponse<UserData>(false, null, "User not found");
}

// Client code
var response = GetUserById(100);
if (response.Success)
{
var user = response.Data;
Console.WriteLine($"Found user: {user.Username} ({user.Email})");
}
else
{
Console.WriteLine($"Error: {response.Message}");
}

// Output:
// Found user: johndoe ([email protected])

Records vs. Classes vs. Structs

Let's compare records to other C# types:

FeatureRecordClassStruct
TypeReferenceReferenceValue
MutabilityImmutable by defaultMutable by defaultMutable by default
EqualityValue-basedReference-basedValue-based
Memory allocationHeapHeapStack (usually)
Primary useImmutable dataBehavior and dataSmall data structures
Support for withYesNoNo
Custom ToString()Yes (automatic)NoNo

Advanced Features

Deconstruction

Records support deconstruction, which allows you to extract the values of a record into separate variables:

csharp
var person = new Person("John", "Doe", 30);

// Deconstruct the record
var (firstName, lastName, age) = person;

Console.WriteLine($"{firstName} {lastName} is {age} years old");

// Output:
// John Doe is 30 years old

Record Structs (C# 10)

Starting with C# 10, you can also define record structs, which combine the features of records with the value type semantics of structs:

csharp
// Record struct (C# 10+)
public record struct Point(double X, double Y);

var p1 = new Point(3, 4);
var p2 = new Point(3, 4);

Console.WriteLine($"Equal: {p1 == p2}");
Console.WriteLine($"Distance from origin: {Math.Sqrt(p1.X * p1.X + p1.Y * p1.Y)}");

// Output:
// Equal: True
// Distance from origin: 5

Best Practices for Using Records

  1. Use records for immutable data models: Records are designed for immutability, so use them when your data doesn't need to change after creation.

  2. Use positional records for simple data: When you have a straightforward data structure with a few properties, positional record syntax is concise.

  3. Use nominal records for complex models: When you need more control over property initialization or have a larger number of properties, nominal records provide more flexibility.

  4. Be mindful of reference types within records: If a record contains reference type properties, those properties themselves can still be mutable. Consider using immutable collection types.

  5. Use inheritance thoughtfully: While records support inheritance, overuse can lead to brittle designs. Use it when there's a true "is-a" relationship.

Summary

C# records offer a concise way to define immutable data models with built-in value equality, a nice string representation, and easy ways to create modified copies. They help reduce boilerplate code and encourage good programming practices, especially for data transfer objects and domain models.

Key points to remember:

  • Records provide value-based equality by default
  • They're immutable by default, but support nondestructive mutation with with
  • They come in positional and nominal syntax forms
  • They're perfect for DTOs, domain objects, and other data-centric types
  • C# 10 added record structs which combine record features with value type semantics

Exercises

  1. Create a Product record with properties for ID, Name, Description, and Price. Then create multiple instances and test equality between them.

  2. Define a base Shape record with properties for X and Y coordinates, then derive Circle and Rectangle records from it.

  3. Create an immutable ShoppingCart record that contains a list of items. Implement a method that returns a new cart with an item added (without modifying the original).

  4. Create a record struct ComplexNumber with real and imaginary parts, and implement methods for addition and multiplication.

Additional Resources



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)