← Back to C# series
✨
Modern C#
Modern C# Β· Prerequisite: classes/properties

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.

C#.NET 8recordinit
Duration
⏱ ~1-1.5 hours
Level
πŸ“Š Intermediate
Prerequisite
🎯 Classes and properties
OUTCOME
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

csharp
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`
CategoryReference type (heap)Value type (stack/inline)
CopyReference copyValue copy
EqualityAuto value equalityAuto value equality
SizeLarge objects OKSmall data (16-24 B) recommended
ExampleDTO, message`Point`, `Vector`

Typically declare `record struct` as `readonly record struct` to enforce immutability.

csharp
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."

csharp
Person p1 = new("Alice", 30);
Person p2 = p1 with { Age = 31 };   // p1 unchanged; p2 is a new object

It uses the compiler-generated clone method (`<Clone>$`). Plain `class`es don't support `with`.

4) `init`-only setter

csharp
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.

csharp
HashSet<Coord> set = [new(1,2), new(1,2)];   // Count == 1

For a `class`, two different references would mean both entries get added.

Examples

Example 1 β€” `RecordBasics`: the power of one record line

csharp
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**

text
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

csharp
Person original = new("Alice", 30, "Seoul");
Person aged     = original with { Age = 31 };
Person moved    = original with { City = "Busan", Age = 31 };

**Output**

text
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

csharp
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**

text
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

csharp
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**

text
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

csharp
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**

text
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

xml
<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

csharp
// 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

csharp
// 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

xml
<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

csharp
// 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

xml
<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

csharp
// 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

xml
<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

csharp
// `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

xml
<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

  1. Forgetting that record properties are `init`-only by default and trying `p.Name = ...` after creation β€” compile error.
  2. Trying the `with` expression on a regular `class` β€” record-only.
  3. Declaring `record struct` without `readonly` β€” mutable structs cause subtle bugs. Prefer `readonly record struct` by default.
  4. Putting a large object into a `record struct` β€” every pass copies it whole, so it can be slower. Generally keep structs ≀ 16 bytes.
  5. 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

text
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 ? True

Hints

  • 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

text
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) ? True

Hints

  • 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

xml
<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

csharp
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

xml
<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

csharp
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

bash
cd src/RecordBasics && dotnet run
cd ../WithExpression && dotnet run
cd ../RecordVsClass && dotnet run
cd ../InitOnly && dotnet run
cd ../ValueEquality && dotnet run

Next 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.

TrackRecommended 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)

Example code / lecture materials

All lecture materials and example code are openly available on GitHub.

View on GitHub β†—