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:
// 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:
// 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.
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:
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:
// 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:
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:
// 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:
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:
// 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:
Feature | Record | Class | Struct |
---|---|---|---|
Type | Reference | Reference | Value |
Mutability | Immutable by default | Mutable by default | Mutable by default |
Equality | Value-based | Reference-based | Value-based |
Memory allocation | Heap | Heap | Stack (usually) |
Primary use | Immutable data | Behavior and data | Small data structures |
Support for with | Yes | No | No |
Custom ToString() | Yes (automatic) | No | No |
Advanced Features
Deconstruction
Records support deconstruction, which allows you to extract the values of a record into separate variables:
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:
// 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
-
Use records for immutable data models: Records are designed for immutability, so use them when your data doesn't need to change after creation.
-
Use positional records for simple data: When you have a straightforward data structure with a few properties, positional record syntax is concise.
-
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.
-
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.
-
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
-
Create a
Product
record with properties for ID, Name, Description, and Price. Then create multiple instances and test equality between them. -
Define a base
Shape
record with properties for X and Y coordinates, then deriveCircle
andRectangle
records from it. -
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). -
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! :)