22. record and init
C# 9's record and init accessors are concise, safe tools for building 'unchanging value objects.' Cover the with expression, value equality, and record struct.
What you'll learn
- 1Know `record`'s auto-generated members (constructor, init properties, Equals, ToString, Deconstruct)
- 2Tell apart `record class` and `record struct`
- 3Do **non-destructive mutation** with the `with` expression
- 4Understand `init`-only setters
- 5Understand **value equality** and why it's useful
Overview
`record` lets you build "data types where the value itself is the meaning" **in a single line**. Combined with `init` setters, value equality, and the `with` expression, it dramatically shortens DTOs, events, and result objects.
Core Concepts
1) The power of one `record` line
record Person(string Name, int Age);That one line auto-generates:
- A `public Person(string name, int age)` constructor
- `public string Name { get; init; }`, `public int Age { get; init; }` properties (both init-only)
- `Equals`, `GetHashCode` β **value-based**
- `ToString` β `Person { Name = Alice, Age = 30 }` form
- `==`, `!=` operators β value comparison
- `Deconstruct` β `var (n, a) = p;`
2) `record class` vs `record struct`
| `record class` (default) | `record struct` | |
|---|---|---|
| Category | Reference type (heap) | Value type (stack/inline) |
| Copy | Reference copy | Value copy |
| Equality | Auto value equality | Auto value equality |
| Size | Large objects OK | Small data (16-24 B) recommended |
| Example | DTO, message | `Point`, `Vector` |
Typically declare `record struct` as `readonly record struct` to enforce immutability.
record class Person(string Name, int Age);
readonly record struct Point(int X, int Y);3) `with` expression β non-destructive copy
`with` creates "a new instance with some properties changed; the original is left alone."
Person p1 = new("Alice", 30);
Person p2 = p1 with { Age = 31 }; // p1 unchanged; p2 is a new objectIt uses the compiler-generated clone method (`<Clone>$`). Plain `class`es don't support `with`.
4) `init`-only setter
class Person
{
public string Name { get; init; } = "";
}- Assignable **only during object initialization** (`new Person { Name = "Alice" }`)
- Read-only afterwards
- Usable on regular classes too, not just records
5) Value equality and collections
`record`'s `Equals`/`GetHashCode` are value-based automatically, so you can use them as keys in **`HashSet<Person>` / `Dictionary<Person, β¦>`** directly.
HashSet<Coord> set = [new(1,2), new(1,2)]; // Count == 1For a `class`, two different references would mean both entries get added.
Examples
Example 1 β `RecordBasics`: the power of one record line
Person p1 = new("Alice", 30);
Person p2 = new("Alice", 30);
Console.WriteLine($"p1 == p2 ? {p1 == p2}"); // True
Console.WriteLine(p1); // auto ToString
var (name, age) = p1; // auto deconstruction**Output**
p1 == p2 ? True
p1 == p3 ? False
Person { Name = Alice, Age = 30 }
name=Alice, age=30**Note:** A one-line declaration gives you 5 auto-generated members. DTO/message creation gets shockingly short.
Example 2 β `WithExpression`: non-destructive copy
Person original = new("Alice", 30, "Seoul");
Person aged = original with { Age = 31 };
Person moved = original with { City = "Busan", Age = 31 };**Output**
original: Person { Name = Alice, Age = 30, City = Seoul }
aged : Person { Name = Alice, Age = 31, City = Seoul }
moved : Person { Name = Alice, Age = 31, City = Busan }
original is still 30, Seoul**Note:** Functional-style "mutation = new object" pattern. Shines especially in concurrent code and LINQ pipelines.
Example 3 β `RecordVsClass`: class vs struct
internal sealed record PointClass(int X, int Y);
internal readonly record struct PointStruct(int X, int Y);
Console.WriteLine($"record class: c1 == c2 ? {c1 == c2} (refs equal? {ReferenceEquals(c1, c2)})");
Console.WriteLine($"record struct: s1 == s2 ? {s1 == s2}");**Output**
record class: c1 == c2 ? True (refs equal? False)
record struct: s1 == s2 ? True
s1 = PointStruct { X = 1, Y = 2 }
shifted = PointStruct { X = 99, Y = 2 }, s1 = PointStruct { X = 1, Y = 2 }**Note:** Both use value equality so `==` agrees. The difference is memory layout β `struct` stores the whole thing inline/on the stack, ideal for small data.
Example 4 β `InitOnly`: init-only setter
internal sealed class Person
{
public string Name { get; init; } = "";
public int Age { get; init; }
}
Person p = new() { Name = "Alice", Age = 30 };
// p.Name = "Bob"; // compile error β init means no mutation after init**Output**
Alice, 30 y/o
C# Intro (320p)**Note:** `init` is not exclusive to records β use it on a normal class to make "immutable after construction." Combine with `required` to also enforce "must be set."
Example 5 β `ValueEquality`: value equality + collections
internal sealed record Coord(int X, int Y);
Coord a = new(1, 2);
Coord b = new(1, 2);
HashSet<Coord> set = [a, b, new(3, 4)]; // a and b are equal β dedup**Output**
a.Equals(b) = True
set.Count = 2
ToString: Coord { X = 1, Y = 2 }
a == b : True
a == c : False**Note:** Works correctly as a `Dictionary<Coord, ...>` key. For a `class` you'd have to override `Equals`/`GetHashCode` by hand.
Full example code (src/)
src/InitOnly/InitOnly.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern22</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
src/InitOnly/Program.cs
// init-only setter β assignable only at object initialization, read-only afterwards.
// Usable on plain classes too (not record-only).
Person p = new()
{
Name = "Alice", // init setter callable inside object initializer
Age = 30,
};
Console.WriteLine($"{p.Name}, {p.Age} y/o");
// p.Name = "Bob"; // compile error: init means no mutation after creation
// Combined with required (lecture 07) you also force "must be set."
Book b = new() { Title = "C# Intro", Pages = 320 };
Console.WriteLine(b);
internal sealed class Person
{
public string Name { get; init; } = "";
public int Age { get; init; }
}
internal sealed class Book
{
public required string Title { get; init; } // required β must set at init
public int Pages { get; init; }
public override string ToString() => $"{Title} ({Pages}p)";
}
src/RecordBasics/Program.cs
// One record line β constructor, init-only properties, Equals, GetHashCode, ToString all auto-generated.
Person p1 = new("Alice", 30);
Person p2 = new("Alice", 30);
Person p3 = new("Bob", 25);
// Value equality β equal values are considered equal (class would compare by reference, returning false).
Console.WriteLine($"p1 == p2 ? {p1 == p2}"); // True
Console.WriteLine($"p1 == p3 ? {p1 == p3}"); // False
// Auto ToString β debugging is a joy.
Console.WriteLine(p1);
// Deconstruction is auto.
var (name, age) = p1;
Console.WriteLine($"name={name}, age={age}");
internal sealed record Person(string Name, int Age);
src/RecordBasics/RecordBasics.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern22</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
src/RecordVsClass/Program.cs
// record class (default) : reference type + value equality
// record struct : value type + value equality
PointClass c1 = new(1, 2);
PointClass c2 = new(1, 2);
PointStruct s1 = new(1, 2);
PointStruct s2 = new(1, 2);
Console.WriteLine($"record class: c1 == c2 ? {c1 == c2} (refs equal? {ReferenceEquals(c1, c2)})");
Console.WriteLine($"record struct: s1 == s2 ? {s1 == s2}");
// struct is value-copied β fully cloned when passed to a function.
void Shift(PointStruct p) { /* p.X = 99; (forbidden because readonly) */ }
Shift(s1);
Console.WriteLine($"s1 = {s1}");
// Both records get auto ToString/Equals/GetHashCode and support `with`.
PointStruct shifted = s1 with { X = 99 };
Console.WriteLine($"shifted = {shifted}, s1 = {s1}");
internal sealed record PointClass(int X, int Y);
internal readonly record struct PointStruct(int X, int Y);
src/RecordVsClass/RecordVsClass.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern22</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
src/ValueEquality/Program.cs
// Record auto-generates: Equals, GetHashCode, ToString, ==, !=, Deconstruct.
Coord a = new(1, 2);
Coord b = new(1, 2);
Coord c = new(3, 4);
// Equals β value comparison
Console.WriteLine($"a.Equals(b) = {a.Equals(b)}");
// GetHashCode β equal values share a hash β usable as HashSet/Dictionary key
HashSet<Coord> set = [a, b, c]; // b is equal to a, so dedup
Console.WriteLine($"set.Count = {set.Count}");
// ToString β auto-formatted as Coord { X = 1, Y = 2 }
Console.WriteLine($"ToString: {a}");
// == is also value-based
Console.WriteLine($"a == b : {a == b}");
Console.WriteLine($"a == c : {a == c}");
internal sealed record Coord(int X, int Y);
src/ValueEquality/ValueEquality.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern22</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
src/WithExpression/Program.cs
// `with` expression β change a few fields and produce a "new instance" (non-destructive copy).
Person original = new("Alice", 30, "Seoul");
Person aged = original with { Age = 31 };
Person moved = original with { City = "Busan", Age = 31 };
Console.WriteLine($"original: {original}");
Console.WriteLine($"aged : {aged}");
Console.WriteLine($"moved : {moved}");
// The original is unchanged.
Console.WriteLine($"\noriginal is still {original.Age}, {original.City}");
internal sealed record Person(string Name, int Age, string City);
src/WithExpression/WithExpression.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern22</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
Common Mistakes
- Forgetting that record properties are `init`-only by default and trying `p.Name = ...` after creation β compile error.
- Trying the `with` expression on a regular `class` β record-only.
- Declaring `record struct` without `readonly` β mutable structs cause subtle bugs. Prefer `readonly record struct` by default.
- Putting a large object into a `record struct` β every pass copies it whole, so it can be slower. Generally keep structs β€ 16 bytes.
- Putting a mutable collection (`List<T>`) inside a record β the inner list is still mutable. Expose `IReadOnlyList<T>` if you really need immutability.
Summary
- `record` line gives you constructor / properties / Equals / ToString / Deconstruct for free
- Value equality lets you use it as a HashSet/Dictionary key directly
- `with` expression does non-destructive copy β suits functional style
- `init` is not record-exclusive β regular classes can use it too
- Small immutable value bundles β `readonly record struct`; otherwise `record class`
Practice
**Practice - 22. Record and init**
Problem 1 β Book record
- Project folder: `Homework01/`
- Key concepts: `record` declaration, `with` expression, value equality
Requirements
- Define `record Book(string Title, string Author, int Pages);`.
- Create `Book original = new("CLR via C#", "Jeffrey Richter", 1100);`.
- Use the `with` expression to make:
- `revised`: same as `original` but `Pages` = 1200.
- `translated`: same as `original` but `Title` = "CLR via C# (Korean Edition)".
- Print all three instances (record's auto `ToString`).
- Print `original == revised` and `original == new("CLR via C#", "Jeffrey Richter", 1100)` (should be False and True).
Expected output
original : Book { Title = CLR via C#, Author = Jeffrey Richter, Pages = 1100 }
revised : Book { Title = CLR via C#, Author = Jeffrey Richter, Pages = 1200 }
translated: Book { Title = CLR via C# (Korean Edition), Author = Jeffrey Richter, Pages = 1100 }
original == revised ? False
original == same-value new ? TrueHints
- Just `record Book(string Title, string Author, int Pages);` is enough.
- `var revised = original with { Pages = 1200 };`
- `==` between two record instances is value comparison.
Problem 2 β Vector record struct
- Project folder: `Homework02/`
- Key concepts: `readonly record struct`, adding methods, operator overloads optional
Requirements
- Define `readonly record struct Vector(double X, double Y)`.
- Inside the record, add instance method `Vector Add(Vector other)` β returns a new `Vector` summing the two.
- Add instance method `double Length()` β `sqrt(X*X + Y*Y)`.
- Print:
- `v1 = (3, 4)`, `v2 = (1, 2)`.
- `v1.Add(v2)` result.
- `v1.Length()` value (5).
- `v1 == new Vector(3, 4)` (True).
Expected output
v1 = Vector { X = 3, Y = 4 }
v2 = Vector { X = 1, Y = 2 }
v1 + v2 = Vector { X = 4, Y = 6 }
|v1| = 5
v1 == new Vector(3, 4) ? TrueHints
- Record body can be expanded: `record struct Vector(double X, double Y) { ... methods ... }`.
- Use `Math.Sqrt`.
- For `readonly record struct`, methods are automatically readonly-context.
Check your answer
Try it yourself, then compare against the [`answer/`](./answer/) folder.
Answer (answer/)
homework/answer/Homework01/Homework01.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern22</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
homework/answer/Homework01/Program.cs
Book original = new("CLR via C#", "Jeffrey Richter", 1100);
// with expression β new instance with a few changes; original unchanged.
Book revised = original with { Pages = 1200 };
Book translated = original with { Title = "CLR via C# (Korean Edition)" };
Console.WriteLine($"original : {original}");
Console.WriteLine($"revised : {revised}");
Console.WriteLine($"translated: {translated}");
Console.WriteLine();
Console.WriteLine($"original == revised ? {original == revised}");
Console.WriteLine($"original == same-value new ? {original == new Book("CLR via C#", "Jeffrey Richter", 1100)}");
internal sealed record Book(string Title, string Author, int Pages);
homework/answer/Homework02/Homework02.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern22</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
homework/answer/Homework02/Program.cs
Vector v1 = new(3, 4);
Vector v2 = new(1, 2);
Console.WriteLine($"v1 = {v1}");
Console.WriteLine($"v2 = {v2}");
Console.WriteLine($"v1 + v2 = {v1.Add(v2)}");
Console.WriteLine($"|v1| = {v1.Length()}");
Console.WriteLine($"v1 == new Vector(3, 4) ? {v1 == new Vector(3, 4)}");
// readonly record struct β small value type + auto value equality + immutable
internal readonly record struct Vector(double X, double Y)
{
public Vector Add(Vector other) => new(X + other.X, Y + other.Y);
public double Length() => Math.Sqrt(X * X + Y * Y);
}
Try It Yourself
cd src/RecordBasics && dotnet run
cd ../WithExpression && dotnet run
cd ../RecordVsClass && dotnet run
cd ../InitOnly && dotnet run
cd ../ValueEquality && dotnet runNext Lecture
Congratulations β you've **completed the 22-lecture C# Intro track**.
Now that you've got the language and standard library down, pick a follow-up track based on what you want to build.
| Track | Recommended starting point |
|---|---|
| **ASP.NET Core 8** | Web APIs Β· MVC Β· Minimal API |
| **Entity Framework Core 8** | DB mapping and LINQ-to-SQL |
| **Unity** (C# game dev) | 2D/3D games, MonoBehaviour lifecycle |
| **.NET MAUI** | Mobile/desktop cross-platform UI |
| **Blazor** | Web frontends written in C# |
[β Back to the course README](../../README.md)
All lecture materials and example code are openly available on GitHub.
View on GitHub β