C# (.NET 8 LTS) track

Lesson 22 · Part 5: Modern C#

Records and init

Create immutable data types with records, init-only setters, value equality and with expressions.

Part 5: Modern C#5 min readFree lesson

record is a keyword that creates “the value itself” data type in one line. By combining modern features such as init only setter, value equality, and with expressions, the creation of DTO, event, and result objects is drastically reduced.

Learning Objectives

  • Know the automatically created members (constructor, init property, Equals, ToString, Deconstruct) of record
  • Know the difference between record class and record struct
  • Perform non-destructive mutation in the with manner.
  • Know the meaning and use of init only setter
  • Know what value equality is and why it is useful

Key concepts

record Person(string Name, int Age);

This one line automatically creates:

  • 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 } format
  • ==, != operators — compare values
  • 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 Copy Reference Copy Value
Equivalence automatic value equality automatic value equality
size Large object OK Small data (16-24 B) recommended
Yes DTO, message Point, Vector

record struct is usually declared as readonly record struct to enforce immutability.

record class    Person(string Name, int Age);
readonly record struct Point(int X, int Y);

with creates “a new instance that leaves the original as is but changes only some properties.”

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

record uses the clone method (<Clone>$) automatically generated by the compiler. with cannot be used on regular class.

4) init-only setters

class Person
{
    public string Name { get; init; } = "";
}
  • Assignment possible only at the time of object initialization (new Person { Name = "Alice" })
  • readonly after object is created
  • Can be used in general classes other than records

Equals/GetHashCode of record are automatically value-based, so they can be used as is with the HashSet<Person> / Dictionary<Person, …> keys.

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

If it was class, the two objects are different references, so both are entered.

See as example

Person p1 = new("Alice", 30);
Person p2 = new("Alice", 30);
Console.WriteLine($"p1 == p2 ? {p1 == p2}");   // True
Console.WriteLine(p1);                          //ToString Automatic
var (name, age) = p1;                           //Disassembly Automatic

Execution result

p1 == p2 ? True
p1 == p3 ? False
Person { Name = Alice, Age = 30 }
name=Alice, age=30

Note: A one-line declaration creates five automatic members. Creating DTO/message objects becomes really short.

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

Execution result

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 years old, Seoul

Note: Functional style "change state = create new object" pattern. It especially shines in concurrent code and LINQ pipelines.

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} (reference? {ReferenceEquals(c1, c2)})");
Console.WriteLine($"record struct: s1 == s2 ? {s1 == s2}");

Execution result

record class: c1 == c2 ? True (reference? 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 have value equality, so the result of == is the same. The difference is memory placement — struct is stored entirely in stack/inline, making it suitable for small data.

internal sealed class Person
{
    public string Name { get; init; } = "";
    public int Age { get; init; }
}

Person p = new() { Name = "Alice", Age = 30 };
//p.Name = "Bob";  // Compilation error — init cannot be changed after object initialization

Execution result

Alice, 30 years old
C# (320p)

Note: init is not a dedicated function of record. It can also be used in general classes to make them “immutable after creation.” When combined with required, "must specify" is also enforced.

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 the same, so remove duplicates

Execution result

a.Equals(b) = True
set.Count = 2
ToString: Coord { X = 1, Y = 2 }
a == b : True
a == c : False

Memo: It works correctly even if you use the Dictionary<Coord, ...> key. If it was class, you would have had to directly override Equals/GetHashCode.

Common Mistakes

  1. I forgot that the default record property is init only, and tried to change it to p.Name = ... after creating it — compilation error.
  2. Try the with expression on regular class — for record only.
  3. Declaring record struct without readonly — mutable struct causes subtle bugs. Basically readonly record struct recommended.
  4. Large objects to record struct — It is actually slower because the entire object is copied each time. Usually only 16 bytes or less in struct.
  5. Leave the mutable collection (List<T>) inside the record — the list inside is still mutable. If you really need immutability, expose IReadOnlyList<T>.

Cleanup

  • Constructor/Properties/Equals/ToString/Deconstruct is automatically created with one line of record
  • Thanks to value equality, it can be used as a HashSet/Dictionary key.
  • Non-destructive copy as with — suitable for functional style
  • init is not dedicated to records but can also be used in general classes.
  • readonly record struct for small, immutable value sets, record class for others.

Try it yourself

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

Example files

The runnable files used in this lesson. Copy them into your own project and experiment.

src/InitOnly/InitOnly.csprojText
<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.csC#
//init only setter — Can be assigned only at the time of object initialization, and readonly thereafter.
//Applicable to general classes (not record only).

Person p = new()
{
    Name = "Alice",      //Init setter can be called within object initialization statement
    Age = 30,
};

Console.WriteLine($"{p.Name}, {p.Age} years old");

//p.Name = "Bob";        // Compilation error: init cannot be changed after object creation. When combined with

//required (Part 07), "must be specified" can be forced.
Book b = new() { Title = "C#", 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 → Required during initialization
    public int Pages { get; init; }
    public override string ToString() => $"{Title} ({Pages}p)";
}
src/RecordBasics/Program.csC#
//record One line — Constructor, properties (init only), Equals, GetHashCode, and ToString are automatically generated.
Person p1 = new("Alice", 30);
Person p2 = new("Alice", 30);
Person p3 = new("Bob", 25);

//Value equality — If the values ​​inside are the same, they are considered equal (if it was a class, it is a reference comparison, which is false).
Console.WriteLine($"p1 == p2 ? {p1 == p2}");   // True
Console.WriteLine($"p1 == p3 ? {p1 == p3}");   // False

//ToString is automatically created — Debugging is fun.
Console.WriteLine(p1);

//Deconstruction is also automatic.
var (name, age) = p1;
Console.WriteLine($"name={name}, age={age}");

internal sealed record Person(string Name, int Age);
src/RecordBasics/RecordBasics.csprojText
<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.csC#
//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} (reference? {ReferenceEquals(c1, c2)})");
Console.WriteLine($"record struct: s1 == s2 ? {s1 == s2}");

//struct copies the value → When passed to a function, it is copied entirely.
void Shift(PointStruct p) { /*p.X = 99; (Not readonly)*/ }
Shift(s1);
Console.WriteLine($"s1 = {s1}");

//ToString / Equals / GetHashCode are automatically generated for both records, and the with expression can be used in the same way.
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.csprojText
<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.csC#
//record automatically creates: Equals, GetHashCode, ToString, ==, !=, Deconstruct.
Coord a = new(1, 2);
Coord b = new(1, 2);
Coord c = new(3, 4);

//Equals — Compare values
Console.WriteLine($"a.Equals(b) = {a.Equals(b)}");

//GetHashCode — If the value is the same, the same hash → HashSet, can be used as a Dictionary key
HashSet<Coord> set = [a, b, c];   //Remove duplicates by considering b equal to a
Console.WriteLine($"set.Count = {set.Count}");

//ToString — Coord { Automatic output in the form 1, Y = 2 }
Console.WriteLine($"ToString: {a}");

//== operator also compares values
Console.WriteLine($"a == b : {a == b}");
Console.WriteLine($"a == c : {a == c}");

internal sealed record Coord(int X, int Y);
src/ValueEquality/ValueEquality.csprojText
<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.csC#
//`with` expression — Creates a “new instance” by changing only part of the record (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 the same.
Console.WriteLine($"\noriginal is still {original.Age}, {original.City}");

internal sealed record Person(string Name, int Age, string City);
src/WithExpression/WithExpression.csprojText
<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>

Assignment

Problem 1 — Book with expressions

  • Reference solution: shown below
  • 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 create the following.
  • revised: A new instance where only Pages was changed from original to 1200.
  • translated: Change Title from original to "CLR via C# (Korean edition)".
  • Print all three instances (using the record's automatic ToString).
  • Output two values, original == revised and original == new("CLR via C#", "Jeffrey Richter", 1100) (False and True must be displayed, respectively).

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 == new object with same value? True

Hint

  • record Book(string Title, string Author, int Pages); alone is sufficient.
  • var revised = original with { Pages = 1200 };
  • Compare the two record instances with ==.

Problem 2 — Vector record struct

  • Reference solution: shown below
  • Core concepts: readonly record struct, method addition, operator overloading is optional

Requirements

  • Define readonly record struct Vector(double X, double Y).
  • Add an instance method Vector Add(Vector other) within the same record — returns a new Vector that adds the two vectors.
  • Add an instance method double Length() within the same record — sqrt(X*X + Y*Y).
  • Print the following:
    • 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) ? True

Hint

  • The record body can be expanded with curly braces like record struct Vector(double X, double Y) { ... methods ... }.
  • Math.Sqrt.
  • If readonly record struct, the method is automatically set to readonly context.

Check the correct answer

After solving it yourself, compare it with the reference solution below.

Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

Homework01/Homework01.csprojText
<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>
Homework01/Program.csC#
Book original = new("CLR via C#", "Jeffrey Richter", 1100);

//with expression — Creates a new instance with only some changes. The original is not changed.
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 == new object with same value? {original == new Book("CLR via C#", "Jeffrey Richter", 1100)}");

internal sealed record Book(string Title, string Author, int Pages);
Homework02/Homework02.csprojText
<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>
Homework02/Program.csC#
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 automatic 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);
}

Learn this lesson with a mentor

Work through the C# (.NET 8 LTS) track on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now