C# 14 — Modern C# from Zero to Hero
From your first line of code to real applications with .NET 10
Welcome to C# 14
This course takes you from zero programming knowledge all the way to writing real, modern C# applications. Each lesson focuses on one idea, explains it from scratch with clear examples, and ends with an interactive quiz that locks in what you just learned.
You will cover everything a productive C# developer needs: the language basics, control flow and methods, collections, the full object-oriented toolkit, generics, LINQ, exception handling, asynchronous programming, and the brand-new features of C# 14 (shipping with .NET 10, 2025).
How this course works
- 22 lessons, each with 4–6 tabbed sections and a quiz.
- You unlock the next lesson by passing its quiz (≥ 67% correct).
- Read at your own pace and type every example yourself — running code is how it sticks.
- We finish with a capstone project: a complete console application that ties everything together.
What you need
- A computer running Windows, macOS, or Linux.
- The free .NET 10 SDK (we install it together in Lesson 1).
- Curiosity. Prior programming experience helps but is not required — every concept starts from the ground up.
💡 Tip: Click Start course below to begin with Lesson 1. Your progress is saved automatically in your browser.
Hello, C#!
C# (pronounced "see sharp") is a modern, statically typed programming language created by Microsoft in 2000, led by Anders Hejlsberg. It runs on .NET, a free, open-source, cross-platform development platform. The latest version, C# 14, ships with .NET 10 (released in 2025) — and that is exactly what this course teaches.
C# blends ideas from several worlds: it is object-oriented like Java, functionally capable like F#, and increasingly concise and expressive with each release. It is statically typed, which means the compiler checks your types before the program ever runs — catching whole categories of bugs early.
Where C# is used
| Area | Technology |
|---|---|
| Web APIs & sites | ASP.NET Core |
| Desktop & mobile | .NET MAUI, WPF, WinUI |
| Games | Unity, Godot |
| Cloud & microservices | Azure, containers |
| AI & data | ML.NET, Semantic Kernel |
Why learn C#?
- Productive yet powerful — high-level enough for beginners, fast enough for production systems.
- One language, everything — web, desktop, mobile, games, cloud.
- World-class tooling — the compiler, debugger, and editors are exceptional.
- Huge ecosystem — hundreds of thousands of ready-made packages on NuGet.
💡 Tip: C# and .NET are completely free and open-source, maintained by Microsoft and the community on GitHub. They run natively on Windows, macOS, and Linux.
To write and run C#, you install the .NET SDK (Software Development Kit). The SDK includes the compiler, the runtime, and the dotnet command-line tool you will use constantly.
- Go to dotnet.microsoft.com/download and download the .NET 10 SDK for your operating system.
- Run the installer (it sets up everything automatically).
- Open a new terminal and verify the installation:
dotnet --version
# 10.0.100
You can inspect the full setup with:
dotnet --info
Choosing an editor
- Visual Studio Code (free, all platforms) + the C# Dev Kit extension — the most popular choice.
- Visual Studio 2026 (Windows/macOS) — a full-featured IDE.
- JetBrains Rider — a powerful cross-platform IDE.
⚠️ Warning: The SDK lets you build apps; the Runtime only runs already-built apps. As a developer, always install the SDK — it contains the runtime too.
Let's create and run your very first C# program. In a terminal:
dotnet new console -o HelloCsharp
cd HelloCsharp
dotnet new console scaffolds a minimal console application. Open Program.cs — in modern C# it is a single line:
Console.WriteLine("Hello, C#!");
Run it:
dotnet run
# Hello, C#!
That's it — you're a C# programmer! 🎉
What just happened?
Console.WriteLine prints a line of text to the screen. But where is the Main method you may have heard about? Thanks to top-level statements (a modern C# feature), you can write the program body directly, and the compiler generates the boilerplate for you. The classic, fully-written form looks like this:
namespace HelloCsharp;
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello, C#!");
}
}
Both compile to the same thing. We'll use the concise top-level style throughout the early lessons.
💡 Tip: Use
Console.WriteLineto print with a trailing newline, andConsole.Writeto print without one.
A C# project is described by a .csproj file. Open HelloCsharp.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
| Setting | Meaning |
|---|---|
TargetFramework | net10.0 — the .NET version (this selects C# 14). |
ImplicitUsings | Auto-imports common namespaces like System so you don't repeat using lines. |
Nullable | Turns on nullable reference types — the compiler helps you avoid null bugs (Lesson 18). |
Useful commands
dotnet build # compile the project
dotnet run # build and run
dotnet clean # remove build outputs
Build artifacts land in the bin/ and obj/ folders — you never edit those by hand, and they are typically excluded from version control.
💡 Tip: With
ImplicitUsingsenabled, namespaces likeSystem,System.Collections.Generic, andSystem.Linqare available everywhere — nousingstatement required.
C# is a compiled language, but not in the same way as C. Understanding the pipeline helps everything else make sense.
Your C# code → C# compiler → IL (Intermediate Language) → CLR + JIT → native machine code
.cs inside a .dll/.exe at runtime
- The C# compiler translates your
.csfiles into IL (Intermediate Language) — a portable, CPU-independent instruction set — packaged into an assembly (a.dllor.exe). - At runtime, the CLR (Common Language Runtime) loads the assembly. Its JIT (Just-In-Time) compiler converts IL into native machine code for your specific CPU, right before it runs.
- The CLR also provides automatic memory management via a garbage collector (GC) — you allocate objects and the runtime frees them for you. No manual
free(), no memory leaks from forgetting to release memory.
This "managed" model is why the same compiled C# can run on Windows, macOS, and Linux. For maximum startup speed you can also publish Native AOT (ahead-of-time) binaries, but the JIT model is the default and works everywhere.
💡 Tip: "Managed code" simply means code that runs under the CLR's supervision, with garbage collection and type safety built in. Almost all C# you write is managed code.
💡 Keep coding: Try every snippet in your own
dotnetproject. Reading teaches you what — typing teaches you how.
Variables and Types
A variable is a named box that holds a value. In C# every variable has a type, which decides what kind of data it can store and what you can do with it.
// Explicit type on the left
int age = 30;
string name = "Ada";
bool isLearning = true;
// 'var' lets the compiler infer the type from the value
var score = 95; // inferred as int
var greeting = "Hello"; // inferred as string
var is not dynamic or untyped — the type is still fixed at compile time, just inferred from the right-hand side. var score = 95; is identical to int score = 95;.
When to use which
- Use
varwhen the type is obvious from the right-hand side (var list = new List<int>();). - Use an explicit type when it improves readability, or when the value alone is ambiguous.
Naming conventions
- Local variables & parameters:
camelCase→userName,totalPrice. - Constants & types:
PascalCase→MaxRetries,Customer. - Names should describe intent:
daysRemainingbeatsd.
💡 Tip: A variable must be assigned before you read it. The compiler rejects use of an unassigned local variable — a small rule that prevents a surprising number of bugs.
C# ships with a set of built-in value types for numbers, characters, and true/false values.
int count = 42; // 32-bit integer
long big = 9_000_000_000; // 64-bit integer (underscores aid readability)
double price = 19.99; // 64-bit floating point
decimal money = 19.99m; // 128-bit decimal — exact, for currency
bool active = true; // true or false
char initial = 'A'; // a single character (single quotes!)
Picking a number type
| Type | Use it for | Notes |
|---|---|---|
int | Whole numbers (default choice) | ±2.1 billion |
long | Very large whole numbers | suffix L |
double | Scientific / general decimals | fast, but approximate |
decimal | Money and finance | exact, suffix m |
// Why decimal matters for money:
double a = 0.1 + 0.2; // 0.30000000000000004 😬
decimal b = 0.1m + 0.2m; // 0.3 ✅
⚠️ Warning: Never store money in
double. Binary floating point cannot represent values like 0.1 exactly, so cents drift over time. Usedecimalfor currency.
Some values never change. C# gives you two ways to express that intent.
const — compile-time constant
A const is baked into the code at compile time. It must be a simple literal known up front, and it is implicitly static.
const double Pi = 3.14159;
const int MaxLoginAttempts = 3;
const string AppName = "TaskMaster";
readonly — runtime constant
A readonly field can be assigned once — either inline or in the constructor — and never again. Use it when the value isn't known until runtime.
class Circle
{
public readonly double Radius; // set once, in the constructor
public Circle(double radius)
{
Radius = radius; // allowed here...
}
// Radius = 5; // ...but a compile error anywhere else
}
const | readonly | |
|---|---|---|
| Set when | Compile time | Runtime (constructor) |
| Value | Literal only | Any expression |
| Per instance? | No (static) | Yes (can differ per object) |
💡 Tip: Reach for constants whenever a "magic number" appears more than once.
const int MaxRetries = 3;documents intent far better than a bare3scattered through your code.
Sometimes you need to turn one type into another. C# offers several mechanisms, from automatic to explicit.
Implicit conversion (safe, automatic)
When there is no risk of data loss, C# converts for you:
int small = 100;
long big = small; // int → long, always safe
double d = big; // long → double, automatic
Explicit conversion (a cast)
When data could be lost, you must ask for it with a cast (type):
double price = 19.99;
int whole = (int)price; // 19 — the .99 is truncated, not rounded
Converting text to numbers
User input arrives as strings. Two tools turn text into numbers:
int a = int.Parse("42"); // throws if the text isn't a number
bool ok = int.TryParse("42", out int b);
// ok == true, b == 42
// On bad input: ok == false, b == 0 (no exception)
💡 Tip: Prefer
TryParsefor any input you don't fully control (user input, files, network). It returnsfalseinstead of throwing, so you can handle bad data gracefully.
This is one of the most important ideas in C#. Every type is either a value type or a reference type, and they behave differently on assignment.
- Value types (
int,double,bool,char,struct,enum) hold their data directly. Copying one copies the value. - Reference types (
string, arrays,class,List<T>...) hold a reference to data living elsewhere. Copying one copies the reference — both names point at the same object.
// Value type: independent copies
int x = 10;
int y = x; // y gets its own copy
y = 99;
// x is still 10
// Reference type: shared object
int[] a = [1, 2, 3];
int[] b = a; // b points at the SAME array
b[0] = 99;
// a[0] is now 99 too!
And null
Reference types can be null — meaning "points at nothing yet". Value types cannot be null unless you opt in with a ? (e.g. int?). We'll explore null safety thoroughly in Lesson 18.
💡 Tip: A handy mental model: value types are like handing someone a photocopy; reference types are like sharing a link to the same document. Edit the photocopy and yours is untouched; edit the shared document and everyone sees it.
💡 Keep coding: Try every snippet in your own
dotnetproject. Reading teaches you what — typing teaches you how.
Operators, Strings & Text
Operators combine values into expressions. C# groups them into a few families.
// Arithmetic
int sum = 7 + 3; // 10
int rem = 7 % 3; // 1 (modulo — the remainder)
int pow = 2 * 2 * 2; // 8
// Comparison — always produce a bool
bool a = 5 > 3; // true
bool b = 5 == 5; // true (== is equality; = is assignment!)
bool c = 5 != 4; // true
// Logical
bool d = (5 > 3) && (2 < 4); // AND — both must be true
bool e = (5 > 3) || (2 > 4); // OR — at least one true
bool f = !true; // NOT — flips it
Compound assignment & increment
int n = 10;
n += 5; // n = n + 5 → 15
n -= 3; // 12
n *= 2; // 24
n++; // 25 (increment by 1)
Precedence
C# follows normal maths precedence: * and / before + and -. Use parentheses to be explicit and readable:
int x = 2 + 3 * 4; // 14, not 20
int y = (2 + 3) * 4; // 20
💡 Tip:
&&and||are short-circuiting: if the left side already decides the result, the right side is never evaluated. This lets you write safe guards likeif (user != null && user.IsActive).
A string is a sequence of characters. Strings are immutable — once created, a string never changes. Every operation that looks like it edits a string actually returns a new one.
string name = "Ada Lovelace";
int len = name.Length; // 12
string upper = name.ToUpper(); // "ADA LOVELACE"
string sub = name.Substring(0, 3); // "Ada"
bool starts = name.StartsWith("Ada"); // true
int index = name.IndexOf("Love"); // 4
string clean = " hi ".Trim(); // "hi"
string fixedUp = name.Replace("Ada", "Augusta"); // "Augusta Lovelace"
Comparing strings
"abc" == "abc" // true — == compares the text
"ABC".Equals("abc",
StringComparison.OrdinalIgnoreCase) // true — case-insensitive
Checking for "empty"
string.IsNullOrEmpty("") // true
string.IsNullOrWhiteSpace(" ") // true — also catches spaces/tabs
⚠️ Warning: Because strings are immutable, building one in a loop with
+=creates a new string every iteration — slow for large loops. We'll fix that withStringBuildershortly.
The cleanest way to build text from values is string interpolation — a $ before the string lets you drop expressions inside { }.
string name = "Ada";
int age = 30;
string msg = $"{name} is {age} years old.";
// "Ada is 30 years old."
string math = $"2 + 2 = {2 + 2}"; // expressions work too
Format specifiers
Add :format inside the braces to control the display:
decimal price = 1234.5m;
$"{price:C}" // "$1,234.50" (currency)
$"{0.25:P}" // "25.00 %" (percent)
$"{255:X}" // "FF" (hex)
$"{42:D5}" // "00042" (padded)
$"{DateTime.Now:yyyy-MM-dd}" // "2025-11-20"
Raw string literals (C# 11+)
For text full of quotes or backslashes — JSON, file paths, regex — use a raw string literal with three or more quotes. No escaping needed:
string json = """
{
"name": "Ada",
"age": 30
}
""";
string path = """C:\Users\Ada\file.txt""";
💡 Tip: Combine them:
$"""..."""is an interpolated raw string, perfect for templating JSON or HTML with embedded values.
When you need to assemble a string piece by piece — especially in a loop — reach for StringBuilder. It edits an internal buffer instead of allocating a new string on every step.
using System.Text;
var sb = new StringBuilder();
for (int i = 1; i <= 5; i++)
{
sb.Append("Line ");
sb.Append(i);
sb.AppendLine(); // adds a line break
}
string result = sb.ToString();
The difference that matters
// ❌ Slow: builds 10,000 throwaway strings
string s = "";
for (int i = 0; i < 10_000; i++) s += i;
// ✅ Fast: one growing buffer
var sb = new StringBuilder();
for (int i = 0; i < 10_000; i++) sb.Append(i);
For a handful of concatenations, plain + or interpolation is perfectly fine and more readable. StringBuilder earns its keep in loops and when building large strings.
💡 Tip: A rough rule: concatenating a known, small number of pieces? Use interpolation. Concatenating in a loop or an unknown number of pieces? Use
StringBuilder.
Modern C# offers a lower-allocation way to work with slices of text and arrays: ReadOnlySpan<char>. A span is a lightweight "window" over existing memory — it lets you look at part of a string without copying it.
string path = "report.2025.csv";
ReadOnlySpan<char> span = path;
ReadOnlySpan<char> ext = span.Slice(span.LastIndexOf('.') + 1); // "csv"
// No new string was allocated to get the extension.
Many built-in methods understand spans, and in C# 14 the conversions between arrays, Span<T>, and ReadOnlySpan<T> are smoother than ever — the compiler inserts them implicitly where it's safe.
int[] numbers = [10, 20, 30, 40, 50];
Span<int> middle = numbers.AsSpan(1, 3); // a view over 20, 30, 40
middle[0] = 99; // edits numbers[1] in place!
You won't reach for spans every day as a beginner — but knowing they exist explains how high-performance .NET code avoids unnecessary allocations.
💡 Tip: A
Span<T>cannot be stored in a field or used acrossawaitboundaries — it only lives on the stack. That restriction is exactly what makes it so cheap and safe.
💡 Keep coding: Try every snippet in your own
dotnetproject. Reading teaches you what — typing teaches you how.
Making Decisions
The if statement runs code only when a condition is true. Add else if and else to handle alternatives.
int score = 82;
if (score >= 90)
{
Console.WriteLine("Grade: A");
}
else if (score >= 80)
{
Console.WriteLine("Grade: B");
}
else
{
Console.WriteLine("Grade: C or below");
}
The condition inside ( ) must be a bool. C# will not let you write if (score) like some other languages — you must compare explicitly: if (score != 0).
Combining conditions
bool canVote = age >= 18 && isCitizen;
if (age >= 13 && age <= 19)
Console.WriteLine("Teenager");
💡 Tip: Always use braces
{ }, even for one-line bodies. It prevents a classic bug where someone adds a second line later that looks guarded by theifbut actually isn't.
For a quick either/or choice, the ternary operator condition ? a : b is more compact than a full if/else.
int age = 20;
string status = age >= 18 ? "adult" : "minor";
// Equivalent if/else:
string status2;
if (age >= 18) status2 = "adult";
else status2 = "minor";
It is an expression — it produces a value — so it slots neatly into assignments and interpolation: $"You are an {(age >= 18 ? "adult" : "minor")}.".
Null-coalescing operators
When a value might be null, these two save a lot of typing:
string? input = null;
string name = input ?? "Anonymous"; // use input, or "Anonymous" if null
input ??= "default"; // assign only if input is currently null
?? means "the left side, unless it's null, in which case the right side." You'll meet its cousin ?. and much more in Lesson 18.
⚠️ Warning: Ternaries are great for simple choices. If you find yourself nesting them (
a ? b : c ? d : e), switch to anif/elseor aswitchexpression — readability wins.
When you compare one value against many options, a switch is clearer than a long if/else if chain.
int day = 3;
switch (day)
{
case 1:
Console.WriteLine("Monday");
break;
case 6:
case 7:
Console.WriteLine("Weekend"); // multiple labels share a body
break;
default:
Console.WriteLine("A weekday");
break;
}
Rules
- Each
casebody ends withbreak(orreturn). Unlike C, C# forbids accidental "fall-through". defaulthandles anything not matched (optional, but recommended).- Stacking labels (
case 6: case 7:) lets several values share one body.
switch works on integers, strings, char, enum, and more:
switch (command.ToLower())
{
case "start": Run(); break;
case "stop": Halt(); break;
default: Help(); break;
}
💡 Tip: A long chain of
if (x == ...) else if (x == ...)comparing the same variable is a strong signal to refactor into aswitch.
Modern C# adds the switch expression — a concise form that returns a value. It's one of the most loved features in the language.
string GradeFor(int score) => score switch
{
>= 90 => "A",
>= 80 => "B",
>= 70 => "C",
>= 60 => "D",
_ => "F", // _ is the catch-all (like default)
};
Compare the styles:
// Statement style — assigns, needs break, more lines
switch (day) { case 0: name = "Sun"; break; /* ... */ }
// Expression style — returns directly, no break
string name = day switch
{
0 => "Sun",
6 => "Sat",
_ => "Weekday",
};
Notice the differences: the value comes before switch, arms use =>, the catch-all is _, and there's no break. The relational patterns (>= 90) hint at pattern matching, which we'll explore fully in Lesson 8.
⚠️ Warning: A switch expression must handle every possible input. If you omit the
_arm and a value isn't matched, it throws at runtime. The compiler warns you when a case is missing — listen to it.
A block is code wrapped in { }. A variable declared inside a block is scoped to that block — it exists only between the braces and vanishes afterward.
if (loggedIn)
{
string welcome = "Hi there!"; // lives only inside this if-block
Console.WriteLine(welcome);
}
// Console.WriteLine(welcome); // ❌ compile error: out of scope
Scope keeps variables close to where they're used and prevents name clashes. Declare variables in the narrowest scope that works.
Nesting
Inner blocks can see outer variables, but not vice versa:
int total = 0; // outer scope
for (int i = 0; i < 3; i++) // i is scoped to the loop
{
total += i; // inner block sees 'total'
}
// 'i' is gone here; 'total' remains
💡 Tip: "Declare late, in the smallest scope." A variable that only matters inside an
ifshould be declared inside thatif. Narrow scope makes code easier to read and reason about.
💡 Keep coding: Try every snippet in your own
dotnetproject. Reading teaches you what — typing teaches you how.
Loops and Iteration
A for loop repeats code a known number of times. It bundles three parts into one line: an initializer, a condition, and an update.
for (int i = 0; i < 5; i++)
{
Console.WriteLine($"i = {i}");
}
// i = 0, 1, 2, 3, 4
Read it as: start i at 0; keep going while i < 5; after each pass run i++.
// Count down
for (int i = 10; i > 0; i--) Console.Write($"{i} ");
// Step by 2
for (int i = 0; i <= 10; i += 2) Console.Write($"{i} ");
Loops are how computers do repetitive work without you writing the same line 1,000 times.
⚠️ Warning: An off-by-one error is the classic loop bug.
i < array.Lengthvisits every index 0..Length-1;i <= array.Lengthovershoots by one and throws. When in doubt, preferforeach(next sections).
Use while when you don't know the number of iterations up front — you loop as long as a condition holds.
int n = 1;
while (n <= 100)
{
n *= 2; // 1, 2, 4, 8, ... 128
}
// n is now 128 (first power of 2 over 100)
The condition is checked before each pass, so a while body may run zero times.
do-while
A do/while checks the condition after the body, so it always runs at least once — ideal for input prompts:
string? line;
do
{
Console.Write("Type 'quit' to exit: ");
line = Console.ReadLine();
}
while (line != "quit");
⚠️ Warning: Every
whileloop needs a way to eventually become false. Forget to update the condition variable and you get an infinite loop. (Press Ctrl+C to stop a runaway console program.)
The foreach loop walks through every item in a collection — no index, no off-by-one risk. It's the idiomatic way to iterate in C#.
string[] names = ["Ada", "Alan", "Grace"];
foreach (string name in names)
{
Console.WriteLine($"Hello, {name}!");
}
It works on anything enumerable — arrays, List<T>, Dictionary<,>, HashSet<T>, and more:
var ages = new Dictionary<string, int>
{
["Ada"] = 36,
["Grace"] = 85,
};
foreach (var (name, age) in ages) // deconstruct each pair
{
Console.WriteLine($"{name} is {age}");
}
Prefer foreach over for whenever you simply need each element. Use for only when you genuinely need the index or are stepping in an unusual way.
⚠️ Warning: You cannot add or remove items from a collection while a
foreachis iterating it — that throws anInvalidOperationException. Collect changes in a separate list, or loop with aforover an index.
Two keywords give you finer control inside any loop.
breakexits the loop entirely, right now.continueskips the rest of the current pass and jumps to the next iteration.
// Stop at the first match
foreach (int n in numbers)
{
if (n < 0)
{
Console.WriteLine("Found a negative — stopping.");
break;
}
Console.WriteLine(n);
}
// Skip the even numbers, print the odds
for (int i = 1; i <= 10; i++)
{
if (i % 2 == 0) continue; // jump to next i
Console.Write($"{i} "); // 1 3 5 7 9
}
These keep loop bodies flat and readable: handle the special case and break/continue, instead of wrapping everything in nested ifs.
💡 Tip:
breakandcontinuealways affect the innermost loop. To leave nested loops at once, refactor into a method andreturn, which is cleaner than labelled jumps.
C# has special operators for slicing sequences: the index-from-end operator ^ and the range operator ...
int[] data = [10, 20, 30, 40, 50];
int last = data[^1]; // 50 — ^1 means "1 from the end"
int secondLast = data[^2]; // 40
int[] firstTwo = data[..2]; // [10, 20] indices 0,1
int[] lastTwo = data[^2..]; // [40, 50]
int[] middle = data[1..4]; // [20, 30, 40] end is exclusive
Read a range a..b as "from index a, up to but not including b." Leaving a side blank means "the start" or "the end".
string word = "Programming";
string head = word[..4]; // "Prog"
string tail = word[^3..]; // "ing"
These make common slicing tasks readable and hard to get wrong, replacing fiddly Substring(start, length) arithmetic.
💡 Tip:
^0is the index past the last element — useful as a range end (data[2..^0]), but indexingdata[^0]directly throws, just likedata[data.Length].
💡 Keep coding: Try every snippet in your own
dotnetproject. Reading teaches you what — typing teaches you how.
Methods
A method is a reusable block of code with a name. It can take parameters (inputs) and return a value (output). Methods are how you break a big problem into small, named pieces.
// returnType Name(parameters)
int Add(int a, int b)
{
return a + b;
}
int sum = Add(3, 4); // call it → 7
A method that returns nothing uses void:
void Greet(string name)
{
Console.WriteLine($"Hello, {name}!");
// no return value
}
Greet("Ada");
Anatomy of a method
- Return type —
int,string,void, ... what it gives back. - Name —
PascalCaseby convention. - Parameters — typed inputs in
( ). - Body — the code that runs when it's called.
💡 Tip: A good method does one thing and has a name that says what that is. If you struggle to name it, it's probably doing too much — split it.
C# parameters are flexible. Three features make calls clearer and shorter.
Optional parameters (defaults)
void Connect(string host, int port = 8080, bool secure = true)
{
// ...
}
Connect("localhost"); // port 8080, secure true
Connect("localhost", 9000); // secure still true
Named arguments
Pass arguments by name to skip optionals or improve clarity:
Connect("localhost", secure: false); // keep default port, set secure
params — a variable number of arguments
params lets the caller pass any number of values, which arrive as a collection:
int Sum(params int[] numbers)
{
int total = 0;
foreach (int n in numbers) total += n;
return total;
}
Sum(); // 0
Sum(1, 2, 3); // 6
Sum(1, 2, 3, 4); // 10
💡 Tip: Named arguments make calls self-documenting —
CreateUser(isAdmin: true)reads far better than a bareCreateUser(true)at the call site.
By default, arguments are passed by value — the method gets a copy. Three modifiers change that.
out — return extra values
The method must assign an out parameter before returning. You met this with TryParse:
if (int.TryParse("42", out int value))
{
Console.WriteLine(value); // 42
}
ref — pass an existing variable for read & write
void Double(ref int x) => x *= 2;
int n = 5;
Double(ref n); // n is now 10
in — pass by reference, but read-only
in avoids copying a large value type while guaranteeing the method won't change it:
double Distance(in Point a, in Point b) { /* reads only */ }
| Modifier | Caller must initialize? | Method can write? |
|---|---|---|
| (none) | yes | copy only |
out | no | must write |
ref | yes | may write |
in | yes | no (read-only) |
💡 Tip: Reach for
outmainly in theTryXpattern. Overusingref/outmakes code hard to follow — often returning a value (or a tuple/record) is clearer.
Overloading lets several methods share a name as long as their parameters differ. The compiler picks the right one based on the arguments you pass.
int Area(int side) => side * side; // square
int Area(int w, int h) => w * h; // rectangle
double Area(double radius) => Math.PI * radius * radius; // circle
Area(5); // calls Area(int)
Area(4, 6); // calls Area(int, int)
Area(2.0); // calls Area(double)
Overloads must differ in the number or types of parameters. They cannot differ only by return type — that's not enough for the compiler to choose.
int Parse(string s) { ... }
// long Parse(string s) { ... } ❌ same parameters → compile error
The whole .NET library uses overloading heavily — Console.WriteLine has versions for int, string, bool, double, and more, which is why it "just works" with any type.
💡 Tip: Use overloads for genuinely the same operation on different inputs. If two methods do different things, give them different names instead.
Expression-bodied members
When a method is a single expression, the => syntax removes the braces and return:
int Square(int x) => x * x;
string Greet(string name) => $"Hi, {name}!";
bool IsEven(int n) => n % 2 == 0;
It's pure shorthand — identical to the full form, just tidier for one-liners.
Local functions
You can define a method inside another method. A local function is only visible to its parent — perfect for a helper you don't want to expose.
int Factorial(int n)
{
if (n < 0) throw new ArgumentException("n must be >= 0");
return Compute(n);
// local helper, hidden from the outside world
int Compute(int x) => x <= 1 ? 1 : x * Compute(x - 1);
}
Local functions keep helpers close to where they're used and can access the enclosing method's variables.
💡 Tip: Prefer a local function over a private method when a helper is only meaningful inside one method. It signals intent and keeps your class surface small.
Methods come in two flavours, a distinction that becomes central once we reach classes (Lesson 9).
- A static method belongs to the type itself. You call it on the type name.
- An instance method belongs to a specific object. You call it on a value.
// static — no object needed
double root = Math.Sqrt(144); // 12
int max = Math.Max(3, 9); // 9
int parsed = int.Parse("100");
// instance — called on a particular object
string name = "ada";
string upper = name.ToUpper(); // operates on THIS string → "ADA"
Math.Sqrt doesn't need "a Math" — it's a pure function on its inputs, so it's static. name.ToUpper() needs an actual string to act on, so it's an instance method.
In top-level programs, helper methods you write are effectively static. Once you start writing classes, you'll decide for each method: does it need object data (instance) or not (static)?
💡 Tip: A method that doesn't touch any object-specific data is a good candidate to be
static. It's easier to test and reason about because its result depends only on its arguments.
💡 Keep coding: Try every snippet in your own
dotnetproject. Reading teaches you what — typing teaches you how.
Arrays and Collections
An array is a fixed-size, ordered block of elements that all share one type. You access elements by a zero-based index.
int[] scores = new int[3]; // three slots, all 0
scores[0] = 90;
scores[1] = 85;
scores[2] = 72;
// Or declare and fill at once:
string[] days = ["Mon", "Tue", "Wed"];
int first = scores[0]; // 90
int count = scores.Length; // 3
The size is fixed at creation — you can change elements, but you can't grow or shrink the array. Indexing out of range throws IndexOutOfRangeException.
Multidimensional arrays
int[,] grid = new int[2, 3]; // 2 rows, 3 columns
grid[0, 1] = 5;
int[][] jagged = [ [1, 2], [3, 4, 5] ]; // rows of different lengths
💡 Tip: Arrays are perfect when the size is known and constant — pixels in an image, days in a week. When the size changes as the program runs, reach for
List<T>instead (next section).
Modern C# (12+) introduces collection expressions — a single, uniform [ ... ] syntax that initializes arrays, lists, spans, and more. The compiler figures out the rest from the target type.
int[] arr = [1, 2, 3];
List<int> list = [1, 2, 3];
HashSet<int> set = [1, 2, 3];
Span<int> span = [1, 2, 3];
The spread element ..
The .. spread operator inlines the elements of another collection:
int[] head = [1, 2, 3];
int[] tail = [7, 8, 9];
int[] all = [..head, 4, 5, 6, ..tail];
// [1, 2, 3, 4, 5, 6, 7, 8, 9]
This replaces a lot of older, clunkier initialization code (new List<int> { ... }, Concat, AddRange) with one readable line — and it works the same way no matter the collection type.
💡 Tip: Collection expressions are the modern default for creating collections. Prefer
List<int> nums = [1, 2, 3];over the oldernew List<int> { 1, 2, 3 }— it's shorter and consistent everywhere.
List<T> is the workhorse collection: an ordered, resizable sequence. The <T> is the element type — List<string>, List<int>, List<Customer>.
List<string> todo = ["Buy milk", "Walk dog"];
todo.Add("Write code"); // append
todo.Insert(0, "Wake up"); // insert at index
todo.Remove("Walk dog"); // remove by value
todo.RemoveAt(0); // remove by index
int n = todo.Count; // how many (note: Count, not Length)
bool has = todo.Contains("Write code");
string first = todo[0]; // indexable like an array
Iterate it like any sequence:
foreach (string task in todo)
Console.WriteLine($"- {task}");
List<T> grows automatically as you add items, which makes it the right default whenever the number of elements isn't fixed.
⚠️ Warning: Note the naming: arrays expose
.Length, butList<T>(and most collections) expose.Count. Mixing them up is a common beginner stumble.
A Dictionary<TKey, TValue> stores key → value pairs and looks values up by key almost instantly. Think of a real dictionary: a word (key) maps to a definition (value).
var ages = new Dictionary<string, int>
{
["Ada"] = 36,
["Grace"] = 85,
};
ages["Alan"] = 41; // add or update
int a = ages["Ada"]; // look up → 36
bool has = ages.ContainsKey("Grace"); // true
Looking up safely
Indexing a missing key throws. Use TryGetValue to look up safely:
if (ages.TryGetValue("Bob", out int age))
Console.WriteLine(age);
else
Console.WriteLine("No entry for Bob");
Iterating yields key/value pairs you can deconstruct:
foreach (var (name, age) in ages)
Console.WriteLine($"{name}: {age}");
💡 Tip: Reach for a dictionary whenever you find yourself searching a list to find an item by some id or name. Key lookups are roughly O(1) — constant time — versus scanning a whole list.
Three more collections solve specific problems elegantly.
HashSet
HashSet<string> seen = ["a", "b"];
seen.Add("a"); // ignored — already present
bool has = seen.Contains("b"); // very fast
// Great for de-duplication and "have I seen this?" checks
Queue
var line = new Queue<string>();
line.Enqueue("first");
line.Enqueue("second");
string next = line.Dequeue(); // "first"
Stack
var history = new Stack<string>();
history.Push("page1");
history.Push("page2");
string back = history.Pop(); // "page2"
A Queue models waiting lines and task pipelines; a Stack models undo history and "go back" navigation.
💡 Tip: Use a
HashSet<T>instead of aList<T>whenever the only thing you care about is membership ("is X in here?") and you don't want duplicates. It's both faster and clearer about intent.
Picking the right collection makes your code clearer and faster. Match the data shape to the structure.
| Need | Use | Why |
|---|---|---|
| Fixed-size sequence | T[] (array) | Lean, size known up front |
| Growing/shrinking list | List<T> | The default for ordered data |
| Look up by key | Dictionary<K,V> | ~O(1) lookups by key |
| Unique items only | HashSet<T> | Fast membership, no duplicates |
| First-in-first-out | Queue<T> | Pipelines, task lines |
| Last-in-first-out | Stack<T> | Undo, navigation history |
A word on Big-O
You'll hear about O(1) vs O(n). Loosely: O(1) means the cost stays the same no matter how big the collection is (a dictionary key lookup); O(n) means cost grows with size (scanning a list). For small data it rarely matters — but choosing a dictionary over a list-scan for lookups can turn a sluggish program into an instant one.
💡 Tip: When unsure, start with
List<T>. If you later notice you're constantly searching it for items by id, that's your signal to switch to aDictionary<,>.
💡 Keep coding: Try every snippet in your own
dotnetproject. Reading teaches you what — typing teaches you how.
Enums and Pattern Matching
An enum is a type with a fixed set of named values. It replaces meaningless "magic numbers" and stray strings with readable, type-safe names.
enum Status
{
Pending, // 0
Active, // 1
Suspended, // 2
Closed, // 3
}
Status s = Status.Active;
if (s == Status.Active)
Console.WriteLine("Account is live");
Each member has an underlying integer (starting at 0) — you can set them explicitly:
enum HttpCode { Ok = 200, NotFound = 404, Error = 500 }
int code = (int)HttpCode.NotFound; // 404
HttpCode c = (HttpCode)200; // HttpCode.Ok
string name = HttpCode.NotFound.ToString(); // "NotFound"
Flags — combinable options
Mark an enum [Flags] and use powers of two to combine values with bitwise OR:
[Flags]
enum Permissions { None = 0, Read = 1, Write = 2, Delete = 4 }
var p = Permissions.Read | Permissions.Write;
bool canWrite = p.HasFlag(Permissions.Write); // true
💡 Tip: Use an
enumwhenever a value is one of a small, known set — order status, log level, day of week. It documents intent and lets the compiler catch typos that a raw string never would.
Often you hold a value of a general type and need to know its specific type. Two operators help.
The is operator with a pattern
is tests the type and, with a declaration pattern, hands you a ready-to-use typed variable:
object value = "hello";
if (value is string text)
{
Console.WriteLine(text.ToUpper()); // text is a string here
}
This is far cleaner than the old test-then-cast dance. It also reads naturally in negated form:
if (value is not int) Console.WriteLine("not a number");
The as operator
as attempts a cast and yields null on failure instead of throwing:
object obj = GetValue();
string? s = obj as string; // null if obj isn't a string
if (s != null) Console.WriteLine(s.Length);
| On a type mismatch | |
|---|---|
(string)obj (cast) | throws InvalidCastException |
obj as string | returns null |
obj is string s | evaluates to false |
💡 Tip: Prefer
iswith a pattern (obj is string s) for the common "check then use" case — it's a single, safe, readable step that gives you the typed variable for free.
Pattern matching lets you inspect the shape of data, not just its value. It shines inside switch expressions.
Type patterns
string Describe(object o) => o switch
{
int i => $"an int: {i}",
string s => $"a string of length {s.Length}",
bool b => $"a bool: {b}",
null => "null",
_ => "something else",
};
Property patterns
Match on an object's properties with { }:
record Order(decimal Total, string Country);
string Shipping(Order o) => o switch
{
{ Total: > 100 } => "Free shipping",
{ Country: "PL" } => "Domestic rate",
{ Total: < 10, Country: "US" } => "Min order not met",
_ => "Standard rate",
};
You can even bind a nested value: { Customer.Address.City: "Warsaw" }. Patterns turn what would be deeply nested ifs into one flat, declarative block.
💡 Tip: Property patterns are perfect for business rules — pricing tiers, discounts, routing. They keep the "what" (the conditions) visible and uncluttered by "how" (manual property access).
Patterns combine with relational operators and the keywords and, or, not to express ranges and conditions clearly.
Relational patterns
string Size(int n) => n switch
{
< 0 => "negative",
0 => "zero",
< 10 => "small",
< 100 => "medium",
_ => "large",
};
Combining with and / or / not
string Classify(char c) => c switch
{
>= 'a' and <= 'z' => "lowercase letter",
>= 'A' and <= 'Z' => "uppercase letter",
>= '0' and <= '9' => "digit",
' ' or '\t' or '\n' => "whitespace",
_ => "symbol",
};
bool IsWeekend(DayOfWeek d) =>
d is DayOfWeek.Saturday or DayOfWeek.Sunday;
These read almost like English — c is >= 'a' and <= 'z' says exactly what it means. Compare that to the older c >= 'a' && c <= 'z': the pattern form lets you drop the repeated c.
💡 Tip:
x is not nullis the modern, readable way to null-check, andx is >= 1 and <= 100is a clean range test. Patterns aren't only forswitch— they work in anyiftoo.
List patterns (C# 11+) match the shape of a sequence — its length and elements — directly.
int[] data = [1, 2, 3];
string Shape(int[] xs) => xs switch
{
[] => "empty",
[var single] => $"one element: {single}",
[var a, var b] => $"two: {a}, {b}",
[1, ..] => "starts with 1",
[.., 9] => "ends with 9",
_ => "something else",
};
The slice pattern .. matches "any number of elements", and you can capture it:
if (data is [var head, .. var rest])
{
Console.WriteLine($"head = {head}"); // 1
Console.WriteLine($"rest = {rest.Length}"); // 2
}
List patterns make parsing command-style input wonderfully clean:
string[] args = ["move", "10", "20"];
string result = args switch
{
["help"] => "Showing help",
["move", var x, var y] => $"Moving to {x},{y}",
_ => "Unknown command",
};
💡 Tip: List patterns plus
switchexpressions are a beginner-friendly way to write little parsers and command handlers — you'll use exactly this style in the capstone project.
💡 Keep coding: Try every snippet in your own
dotnetproject. Reading teaches you what — typing teaches you how.
Classes and Objects
A class is a blueprint that bundles data (fields) and behaviour (methods) into a new type. An object is a concrete instance of that blueprint, created with new.
class Dog
{
public string Name = ""; // data (a field)
public void Bark() // behaviour (a method)
{
Console.WriteLine($"{Name} says Woof!");
}
}
// Create objects from the blueprint:
Dog rex = new Dog();
rex.Name = "Rex";
rex.Bark(); // Rex says Woof!
Dog fido = new Dog(); // a separate, independent object
fido.Name = "Fido";
rex and fido are two distinct objects, each with its own Name. This is the heart of object-oriented programming: model real things as objects that carry their own data and know how to act on it.
💡 Tip: Class names are
PascalCaseand usually nouns —Customer,Invoice,HttpClient. A class should represent one clear concept.
A class's fields hold its state; its methods define what it can do. Methods can read and change the object's own fields directly.
class BankAccount
{
public string Owner = "";
public decimal Balance = 0m;
public void Deposit(decimal amount)
{
Balance += amount; // updates this object's field
}
public bool Withdraw(decimal amount)
{
if (amount > Balance) return false; // not enough money
Balance -= amount;
return true;
}
}
var acc = new BankAccount { Owner = "Ada" };
acc.Deposit(100m);
acc.Withdraw(30m);
Console.WriteLine(acc.Balance); // 70
Notice the method bodies refer to Balance with no prefix — inside an instance method, an unqualified field name means this object's field.
⚠️ Warning: Public fields like
Balancelet anyone set any value (even a negative balance!). In the next lesson we'll replace raw fields with properties to protect an object's data — a cornerstone of good design.
A constructor runs when an object is created. It has the same name as the class and no return type. Use it to put the object into a valid initial state.
class Person
{
public string Name;
public int Age;
public Person(string name, int age) // constructor
{
Name = name;
Age = age;
}
}
var p = new Person("Ada", 36); // constructor runs here
Now you can't create a Person without supplying a name and age — the type enforces it. You can provide several constructors (overloading applies):
class Point
{
public int X, Y;
public Point(int x, int y) { X = x; Y = y; }
public Point() : this(0, 0) { } // delegates to the other constructor
}
: this(0, 0) chains one constructor to another so you don't repeat code.
💡 Tip: A constructor's job is to guarantee an object starts out valid. Validate arguments here (e.g. throw if age is negative) so a broken object can never exist in the first place.
Modern C# (12+) offers primary constructors — constructor parameters declared right on the class header. They're in scope throughout the whole class body, cutting away boilerplate.
// Primary constructor: 'name' and 'age' are available everywhere in the class
class Person(string name, int age)
{
public string Name => name;
public int Age => age;
public void Introduce() =>
Console.WriteLine($"Hi, I'm {name}, age {age}.");
}
var p = new Person("Ada", 36);
p.Introduce();
Compare with the classic form — the primary constructor removes the repetitive "declare field, then assign it":
// Classic (more boilerplate)
class Person
{
private readonly string _name;
public Person(string name) { _name = name; }
}
// Primary constructor (concise)
class Person(string name)
{
// 'name' is usable directly
}
They're especially handy for passing in dependencies (Lesson 12) and for small data-holding classes.
💡 Tip: Primary constructor parameters aren't automatically public properties (that's records — Lesson 13). They're captured values you can use inside the class. Expose them deliberately via properties when callers need to read them.
Object initializers
You can set accessible members right after construction using { } — no extra constructor needed:
var acc = new BankAccount
{
Owner = "Grace",
Balance = 500m,
};
This runs the constructor first, then assigns each listed member. It keeps object creation readable when there are many optional values.
The this keyword
this refers to the current object. It's required when a parameter name shadows a field, and handy for clarity:
class Rectangle
{
private int width;
public Rectangle(int width)
{
this.width = width; // this.width = the field; width = the parameter
}
public Rectangle Grow()
{
this.width *= 2;
return this; // return the same object (enables chaining)
}
}
💡 Tip: Returning
thisfrom methods enables a fluent style:rect.Grow().Grow().Grow(). Many builder-style APIs use exactly this trick.
A static member belongs to the class itself, not to any one object. There's a single shared copy for the whole program.
class Counter
{
public static int TotalCreated = 0; // shared across all instances
public int Id;
public Counter()
{
TotalCreated++; // increments the shared counter
Id = TotalCreated; // each object gets a unique id
}
}
new Counter(); // TotalCreated = 1
new Counter(); // TotalCreated = 2
Console.WriteLine(Counter.TotalCreated); // 2 — accessed via the class name
Static methods and classes
Static methods don't need an object (you've used Math.Max, int.Parse). A class that is only helpers can be marked static so no one can instantiate it:
static class MathHelpers
{
public static int Square(int n) => n * n;
}
int nine = MathHelpers.Square(3);
⚠️ Warning: Static fields are shared global state. They're great for genuine constants and counters, but mutable static data can cause subtle bugs and makes testing harder. Use it sparingly and deliberately.
💡 Keep coding: Try every snippet in your own
dotnetproject. Reading teaches you what — typing teaches you how.
Properties and Encapsulation
Encapsulation means hiding an object's internals and exposing a controlled surface. Access modifiers are the tool: they decide who can see each member.
| Modifier | Visible to |
|---|---|
public | Everyone |
private | Only this class (the default for fields) |
protected | This class and its subclasses |
internal | Code in the same project/assembly |
file | Only the same source file |
class Account
{
private decimal balance; // hidden — internal detail
public string Owner = ""; // visible to all
public decimal GetBalance() => balance; // controlled read access
}
The guiding principle: make members as private as possible. Expose only what callers genuinely need. A small, deliberate public surface is easier to use correctly and safer to change later.
💡 Tip: Default to
private. Promote a member topubliconly when something outside the class truly needs it. It's far easier to widen access later than to take it back once people depend on it.
A property looks like a field from the outside but is backed by get/set accessors — giving you a clean way to control access. The compiler writes the boilerplate for an auto-property:
class Person
{
public string Name { get; set; } // readable and writable
public int Age { get; private set; } // readable by all, writable only inside the class
public string Id { get; } // read-only, set once in a constructor
}
Usage feels exactly like a field:
var p = new Person();
p.Name = "Ada"; // calls the setter
Console.WriteLine(p.Name); // calls the getter
Why not just use a public field? Because a property is a method in disguise. You can later add validation, logging, or change notification without changing how callers use it. Fields can't do that — switching a public field to a property is a breaking change, but a property was future-proof from day one.
💡 Tip: Always expose data as properties, never as public fields. It costs nothing today and preserves your freedom to add logic tomorrow.
Sometimes an auto-property needs a little logic — validation, trimming, clamping — but writing a full backing field by hand is tedious. C# 14 introduces the field keyword: inside an accessor, field refers to the compiler-generated backing field. You add logic without declaring the field yourself.
class Person
{
public string Name
{
get;
set => field = value?.Trim()
?? throw new ArgumentNullException(nameof(value));
}
public int Age
{
get;
set => field = value < 0 ? 0 : value; // clamp negatives to 0
}
}
Before C# 14, the same thing required a manual backing field:
// The old way — more boilerplate
private string _name = "";
public string Name
{
get => _name;
set => _name = value?.Trim() ?? throw new ArgumentNullException();
}
The field keyword removes that ceremony: you keep the concise auto-property look while slipping in exactly the logic you need. value is the incoming value; field is where it gets stored.
💡 Tip: Reach for
fieldwhen a property needs light logic in one accessor. If both accessors grow complex, a clearly-named manual backing field can still be more readable — use whichever communicates best.
Modern C# lets you build objects that are immutable after construction yet still easy to initialize.
init-only setters
An init accessor can be set only during object creation, then becomes read-only:
class Config
{
public string Host { get; init; } = "localhost";
public int Port { get; init; } = 8080;
}
var c = new Config { Host = "example.com", Port = 443 };
// c.Port = 80; ❌ compile error — init-only, can't change after creation
required members
Mark a member required and the compiler forces callers to set it in the initializer:
class User
{
public required string Email { get; init; }
public string? DisplayName { get; init; }
}
var u = new User { Email = "[email protected]" }; // ✅
// var bad = new User { }; ❌ compile error — Email is required
Together they give you objects that are guaranteed-complete and can't be mutated by accident — without writing a verbose constructor.
💡 Tip:
required+initis a great combo for configuration and data objects: callers must provide the essentials, and nobody can change them afterward.
A property doesn't have to store anything — it can compute its value on demand. These are read-only expression-bodied properties:
class Rectangle
{
public double Width { get; init; }
public double Height { get; init; }
// Computed from other properties — no backing field
public double Area => Width * Height;
public double Perimeter => 2 * (Width + Height);
public bool IsSquare => Width == Height;
}
var r = new Rectangle { Width = 4, Height = 3 };
Console.WriteLine(r.Area); // 12 — recalculated each time it's read
Computed properties keep derived values always in sync with their inputs — there's no stored Area to forget to update. Use them whenever a value is a pure function of other state.
class Person
{
public string First { get; init; } = "";
public string Last { get; init; } = "";
public string FullName => $"{First} {Last}";
}
💡 Tip: If a value can always be derived from existing data, prefer a computed property over storing (and manually maintaining) a separate field. Fewer fields means fewer ways to be inconsistent.
Let's put it together. A well-encapsulated class guards its own invariants — rules that must always hold — so no caller can break it.
class BankAccount
{
public string Owner { get; }
public decimal Balance { get; private set; } // outsiders read; only we write
public BankAccount(string owner, decimal opening)
{
if (opening < 0)
throw new ArgumentException("Opening balance can't be negative.");
Owner = owner;
Balance = opening;
}
public void Deposit(decimal amount)
{
if (amount <= 0) throw new ArgumentException("Amount must be positive.");
Balance += amount;
}
public bool Withdraw(decimal amount)
{
if (amount <= 0) throw new ArgumentException("Amount must be positive.");
if (amount > Balance) return false; // enforce the rule
Balance -= amount;
return true;
}
}
Because Balance has a private setter and all changes go through Deposit/Withdraw, the balance can never go negative or be set arbitrarily. The class is the single guardian of its own correctness.
💡 Tip: Good encapsulation means a class can't be put into an invalid state from the outside. Funnel every change through methods that enforce the rules, and keep the raw data private.
💡 Keep coding: Try every snippet in your own
dotnetproject. Reading teaches you what — typing teaches you how.
Inheritance and Polymorphism
Inheritance lets one class build on another. The derived (child) class gets all the members of the base (parent) class and can add more. Use a colon : to inherit.
class Animal
{
public string Name = "";
public void Eat() => Console.WriteLine($"{Name} is eating.");
}
class Dog : Animal // Dog inherits everything from Animal
{
public void Fetch() => Console.WriteLine($"{Name} fetches the ball.");
}
var d = new Dog { Name = "Rex" };
d.Eat(); // inherited from Animal
d.Fetch(); // defined on Dog
Inheritance models an "is-a" relationship: a Dog is an Animal. That means a Dog can be used anywhere an Animal is expected:
Animal a = new Dog { Name = "Rex" }; // a Dog IS an Animal
a.Eat(); // ✅
// a.Fetch(); ❌ — the variable's type is Animal, which has no Fetch
⚠️ Warning: Don't reach for inheritance just to reuse code. Use it only for genuine "is-a" relationships. For "has-a" (a
Carhas anEngine), prefer composition — hold the other object as a field.
Polymorphism ("many forms") lets a derived class change how an inherited method behaves. Mark the base method virtual, then override it in the child.
class Animal
{
public virtual string Speak() => "..."; // can be overridden
}
class Dog : Animal
{
public override string Speak() => "Woof!";
}
class Cat : Animal
{
public override string Speak() => "Meow!";
}
Now the actual object type decides which method runs, even through a base-typed variable:
List<Animal> zoo = [new Dog(), new Cat(), new Animal()];
foreach (Animal a in zoo)
Console.WriteLine(a.Speak()); // Woof! / Meow! / ...
This is the magic of polymorphism: one loop, one call (a.Speak()), but each object responds in its own way. Add a new Animal subclass later and the loop just works — no changes needed.
💡 Tip:
virtualin the base means "subclasses may replace me."overridein the child means "I am replacing the base version." Both keywords are required — the pairing is deliberate and explicit.
Sometimes a base class is incomplete on its own — there's no sensible default. Mark it abstract: it can't be instantiated, only inherited. Abstract members have no body and must be implemented by subclasses.
abstract class Shape
{
public abstract double Area(); // no body — each shape differs
public void Describe() => // a normal, shared method
Console.WriteLine($"Area is {Area():F2}");
}
class Circle : Shape
{
public double Radius { get; init; }
public override double Area() => Math.PI * Radius * Radius;
}
class Square : Shape
{
public double Side { get; init; }
public override double Area() => Side * Side;
}
// var s = new Shape(); ❌ can't instantiate an abstract class
Shape shape = new Circle { Radius = 2 };
shape.Describe(); // Area is 12.57
An abstract class can mix abstract members (subclasses must fill them in) with concrete ones (shared behaviour). It's the right tool when subclasses share logic and must each supply some specifics.
💡 Tip: Use an
abstractclass when "a Shape" has no meaning by itself but all shapes share some behaviour. If there's no shared implementation at all — just a contract — prefer an interface (next lesson).
Calling the base version with base
An override can still invoke the parent's implementation via base — useful for extending rather than fully replacing behaviour:
class Vehicle
{
public virtual string Describe() => "A vehicle";
}
class Car : Vehicle
{
public string Model = "";
public override string Describe() =>
base.Describe() + $", specifically a {Model}"; // build on the base
}
base(...) also chains constructors from child to parent:
class Animal(string name) { public string Name = name; }
class Dog(string name) : Animal(name) { } // pass 'name' up to Animal
Preventing further inheritance with sealed
Mark a class sealed to forbid inheriting from it, or seal a single override to stop further overriding:
sealed class StripePayment : PaymentMethod { } // no subclasses allowed
💡 Tip:
sealeddocuments "this design is final" and can let the runtime optimize calls. Many framework classes are sealed on purpose. Seal classes that aren't designed to be extended.
Every type in C# ultimately inherits from object, which provides a few methods worth overriding.
ToString — readable text for your type
class Point
{
public int X { get; init; }
public int Y { get; init; }
public override string ToString() => $"({X}, {Y})";
}
var p = new Point { X = 3, Y = 4 };
Console.WriteLine(p); // (3, 4) — WriteLine calls ToString automatically
Without an override, the default ToString() just prints the type name — rarely useful.
Equals & GetHashCode — value equality
By default, two objects are "equal" only if they're the same instance. To compare by content, override both Equals and GetHashCode (always together):
public override bool Equals(object? obj) =>
obj is Point p && p.X == X && p.Y == Y;
public override int GetHashCode() => HashCode.Combine(X, Y);
This is fiddly and easy to get wrong — which is exactly why records (Lesson 13) generate all of it for you automatically.
💡 Tip: If you override
Equals, you must overrideGetHashCodeconsistently, or your objects will misbehave in dictionaries and sets. Better yet: use arecordand let the compiler do it perfectly.
💡 Keep coding: Try every snippet in your own
dotnetproject. Reading teaches you what — typing teaches you how.
Interfaces and Abstraction
An interface is a pure contract: a list of members a type promises to provide, with no implementation. By convention, interface names start with I.
interface ILogger
{
void Log(string message); // no body — just the signature
}
A class implements an interface (same : syntax as inheritance) and must supply every member:
class ConsoleLogger : ILogger
{
public void Log(string message) =>
Console.WriteLine($"[LOG] {message}");
}
class FileLogger : ILogger
{
public void Log(string message) =>
File.AppendAllText("app.log", message + "\n");
}
Now both types are interchangeable wherever an ILogger is expected. Interfaces describe what a type can do, never how — that's the implementer's job.
💡 Tip: An interface is a promise: "any type that implements me has these members." Code that depends on the interface doesn't care which concrete class shows up — that flexibility is the whole point.
A class can inherit from only one base class, but it can implement many interfaces. This is how C# gets the flexibility of multiple inheritance without its pitfalls.
interface IReadable { string Read(); }
interface IWritable { void Write(string data); }
class TextFile : IReadable, IWritable // implements both
{
private string contents = "";
public string Read() => contents;
public void Write(string data) => contents += data;
}
Explicit implementation
When two interfaces declare a member with the same name — or you want to hide a member unless accessed through its interface — implement it explicitly by qualifying the name:
class Device : IReadable, IDisposable
{
string IReadable.Read() => "data"; // only visible via an IReadable reference
void IDisposable.Dispose() { /* cleanup */ }
}
IReadable r = new Device();
r.Read(); // ✅ reachable through the interface
💡 Tip: Most of the time, implement interface members normally (publicly). Use explicit implementation only to resolve name clashes or to keep an interface member off the class's main public surface.
Interfaces can provide a default implementation for a member. Implementers may use it as-is or override it — handy for adding to an interface without breaking everyone who already implements it.
interface IGreeter
{
string Name { get; }
// Default method — implementers get this for free
void Greet() => Console.WriteLine($"Hello, I'm {Name}.");
}
class Robot : IGreeter
{
public string Name => "R2";
// No Greet() needed — uses the default
}
IGreeter g = new Robot();
g.Greet(); // Hello, I'm R2.
A type can still override the default when it needs different behaviour:
class Pirate : IGreeter
{
public string Name => "Roberts";
public void Greet() => Console.WriteLine("Arr, I be Roberts!");
}
⚠️ Warning: Default interface methods are a tool for API evolution, not a replacement for abstract base classes. Use them sparingly — most interfaces are still cleanest as pure contracts with no implementation.
The real power of interfaces is decoupling: write code against the contract, not a concrete type, and you can swap implementations freely.
// This method works with ANY ILogger — console, file, network, fake...
void ProcessOrder(Order order, ILogger logger)
{
logger.Log($"Processing order {order.Id}");
// ... do work ...
logger.Log("Done");
}
ProcessOrder(order, new ConsoleLogger()); // print to screen
ProcessOrder(order, new FileLogger()); // write to a file
ProcessOrder neither knows nor cares which logger it got — it only relies on the Log method the interface promises. This is the classic guideline "program to an interface, not an implementation."
It's also what makes code testable: in a unit test you pass a fake logger that records calls in memory, with no real files or network involved.
💡 Tip: Whenever a class needs to talk to something external — a database, a clock, an email sender — depend on an interface for it. You gain the freedom to swap real implementations for test doubles and alternative providers.
Dependency Injection (DI) is a simple idea with a fancy name: instead of a class creating the things it needs, it receives them from outside (usually through its constructor).
// ❌ Tightly coupled — OrderService is stuck with ConsoleLogger forever
class OrderService
{
private readonly ILogger logger = new ConsoleLogger();
}
// ✅ Injected — the dependency is handed in, so it's swappable
class OrderService(ILogger logger) // primary constructor
{
public void Place(Order o) => logger.Log($"Placed {o.Id}");
}
Now the caller decides what to inject:
var service = new OrderService(new FileLogger());
This keeps classes loosely coupled and easy to test. .NET has a built-in DI container (used everywhere in ASP.NET Core) that wires these dependencies together automatically:
services.AddSingleton<ILogger, ConsoleLogger>();
// Ask for an ILogger anywhere, and DI supplies a ConsoleLogger.
💡 Tip: The pattern in one sentence: depend on interfaces, and accept them through the constructor. That single habit makes your code flexible, testable, and ready for real frameworks.
💡 Keep coding: Try every snippet in your own
dotnetproject. Reading teaches you what — typing teaches you how.
Structs, Records and Immutability
A struct is a value type you define yourself — like the built-in int or bool. It's copied on assignment and typically holds a small bundle of related data.
struct Point
{
public int X { get; init; }
public int Y { get; init; }
}
Point a = new Point { X = 1, Y = 2 };
Point b = a; // a full COPY — independent
b = b with { X = 9 }; // changing b doesn't touch a
The class-vs-struct difference is the value vs reference behaviour from Lesson 2:
// class → reference type → 'b = a' shares one object
// struct → value type → 'b = a' copies the data
When to use a struct
- The data is small (a few fields) and naturally a single value — a point, a colour, a money amount, a date range.
- It makes sense to copy it around cheaply.
⚠️ Warning: Keep structs small and ideally immutable. Large structs are expensive to copy, and mutable structs cause surprising bugs (you often edit a copy, not the original). When in doubt, use a
class.
A record is a reference type designed for data. With one line you get value-based equality, a readable ToString, deconstruction, and non-destructive copying — all generated for you.
record Person(string Name, int Age);
var ada1 = new Person("Ada", 36);
var ada2 = new Person("Ada", 36);
Console.WriteLine(ada1 == ada2); // True — compared by VALUE, not reference!
Console.WriteLine(ada1); // Person { Name = Ada, Age = 36 }
Contrast that with a class, where == is true only for the same instance. Records are perfect for DTOs, API models, configuration, events — anywhere an object is really just a "bag of values".
The positional parameters become init-only properties automatically:
var p = new Person("Ada", 36);
Console.WriteLine(p.Name); // Ada
// p.Name = "Grace"; ❌ init-only by default — records favour immutability
💡 Tip: Reach for a
recordwhenever a type's identity is defined by its data rather than a unique object. "Two people with the same name and age are equal" → record. "Two accounts are different even with identical balances" → class.
A record struct combines both ideas: the value-type behaviour of a struct with the auto-generated equality, ToString, and deconstruction of a record.
record struct Point(int X, int Y);
var p1 = new Point(1, 2);
var p2 = new Point(1, 2);
Console.WriteLine(p1 == p2); // True — value equality, for free
Console.WriteLine(p1); // Point { X = 1, Y = 2 }
By default a record struct is mutable (its properties have set). Add readonly to make it fully immutable — usually the better choice:
readonly record struct Money(decimal Amount, string Currency);
| Type | Stored as | Equality |
|---|---|---|
class | reference | by identity (same instance) |
record | reference | by value (auto) |
struct | value (copied) | by value (manual unless...) |
record struct | value (copied) | by value (auto) |
💡 Tip:
readonly record structis an excellent default for tiny immutable values like coordinates, money, or measurements — small, copy-friendly, and value-compared with zero boilerplate.
Records (and structs) are best used immutable: never modify one, create a modified copy. The with expression does exactly that — copy everything, change only the named members.
record Person(string Name, int Age, string City);
var ada = new Person("Ada", 36, "London");
var older = ada with { Age = 37 }; // copy, change Age
var moved = ada with { City = "Paris" }; // copy, change City
// 'ada' is untouched: Ada, 36, London
This non-destructive mutation is the heart of immutable design. Instead of editing shared state (and risking bugs where one part of your program changes data another part relied on), you produce a new value and pass it along.
// A tiny "update" in immutable style
Person Promote(Person p) => p with { City = "HQ" };
💡 Tip: Immutability eliminates a whole category of bugs: if a value can never change after creation, no faraway code can secretly modify it. Favour immutable records +
withfor data you pass around your program.
Deconstruction unpacks an object's values into separate variables in one step. Records support it automatically.
record Person(string Name, int Age);
var p = new Person("Ada", 36);
var (name, age) = p; // deconstruct into two variables
Console.WriteLine($"{name} is {age}"); // Ada is 36
It pairs beautifully with pattern matching (Lesson 8):
string Describe(Person p) => p switch
{
("Ada", _) => "It's Ada!",
(_, < 18) => "A minor",
(var n, var a) => $"{n}, age {a}",
};
You can add deconstruction to any type — including classes and structs — by writing a Deconstruct method:
class Point
{
public int X, Y;
public void Deconstruct(out int x, out int y) { x = X; y = Y; }
}
var (px, py) = new Point { X = 3, Y = 4 };
💡 Tip: Deconstruction shines when returning multiple values via tuples:
(string name, int age) GetUser() => ("Ada", 36);thenvar (name, age) = GetUser();. Clean, named, no extra type needed.
Four type kinds, one decision. Here's how to choose.
| Question | Lean toward |
|---|---|
| Identity matters (two are different even with same data)? | class |
| It's a bag of immutable data; value equality wanted? | record |
| Small, value-semantics, copied cheaply? | struct |
| Small value data and want auto equality? | record struct |
Rules of thumb
- Default to
classfor entities with identity and behaviour (aCustomer, aGameWorld). - Use
recordfor data you pass around and compare by content (API models, DTOs, events, config). - Use
struct/readonly record structfor tiny values (coordinates, money) where copying is cheap and natural.
class Customer { /* identity + behaviour */ }
record OrderDto(int Id, decimal Total); // data, value equality
readonly record struct Coordinate(double Lat, double Lng); // tiny value
💡 Tip: When unsure, start with a
classorrecord(reference types). Reach for astructonly when you have a specific reason — a small value type copied frequently — and you've confirmed it's worth it.
💡 Keep coding: Try every snippet in your own
dotnetproject. Reading teaches you what — typing teaches you how.
Generics
Generics let you write code that works with any type while staying fully type-safe. The classic motivation: you don't want to write a separate IntList, StringList, and PersonList — you want one List<T>.
// Without generics you'd lose type safety using 'object':
object box = 42;
int n = (int)box; // casts everywhere, runtime errors waiting to happen
// Generics keep the real type:
List<int> numbers = [1, 2, 3];
int first = numbers[0]; // no cast — the compiler knows it's int
numbers.Add("oops"); // ❌ compile error — caught immediately
The <T> is a type parameter — a placeholder filled in when you use the type. List<int> plugs int into T; List<string> plugs in string. One definition, infinite type-safe variations.
Generics give you the best of both worlds: reusability (write once) and type safety (no casts, errors caught at compile time) — with no runtime performance cost.
💡 Tip: If you ever catch yourself writing the same class or method repeatedly, differing only by the type it operates on, that's the signal to make it generic.
A generic method declares its own type parameter in angle brackets after the name. The compiler usually infers it from the arguments, so calls stay clean.
T First<T>(IEnumerable<T> items)
{
foreach (T item in items) return item;
throw new InvalidOperationException("empty");
}
int a = First([10, 20, 30]); // T inferred as int
string s = First(["a", "b"]); // T inferred as string
A common, genuinely useful example — swap two values of any type:
void Swap<T>(ref T x, ref T y)
{
(x, y) = (y, x); // tuple swap
}
int p = 1, q = 2;
Swap(ref p, ref q); // p=2, q=1
string m = "a", n = "b";
Swap(ref m, ref n); // works for strings too
One method, every type, full type checking. Without generics you'd either duplicate the method per type or fall back to object and lose safety.
💡 Tip: You can specify the type explicitly —
First<int>(list)— but usually inference handles it. Let the compiler figure outTwhenever it can; it keeps call sites readable.
A generic class parameterizes the whole type. This is how every collection in .NET is built. Here's a simple type-safe stack:
class Box<T>
{
private T? contents;
public void Put(T item) => contents = item;
public T? Take() => contents;
}
var intBox = new Box<int>();
intBox.Put(42);
int value = intBox.Take() ?? 0; // strongly typed — no cast
var nameBox = new Box<string>();
nameBox.Put("Ada");
A class can take several type parameters — exactly what Dictionary<TKey, TValue> does:
class Pair<TFirst, TSecond>
{
public required TFirst First { get; init; }
public required TSecond Second { get; init; }
}
var p = new Pair<string, int> { First = "Age", Second = 36 };
Inside the class, T is used like any real type — as a field type, parameter, return type, or property.
💡 Tip: Naming convention: a single type parameter is
T; descriptive ones use aTprefix —TKey,TValue,TResult,TItem. It makes multi-parameter generics far easier to read.
By default a generic T could be anything, so you can only do "anything" operations with it. Constraints (where) restrict T, unlocking more capability while keeping safety.
// T must implement IComparable<T>, so we can call CompareTo
T Max<T>(T a, T b) where T : IComparable<T>
=> a.CompareTo(b) >= 0 ? a : b;
int big = Max(3, 9); // 9
string late = Max("apple", "pear"); // "pear"
Common constraints:
| Constraint | Means |
|---|---|
where T : class | T is a reference type |
where T : struct | T is a value type |
where T : SomeBase | T derives from SomeBase |
where T : IFoo | T implements IFoo |
where T : new() | T has a public parameterless constructor |
where T : notnull | T is non-nullable |
// 'new()' lets the method construct a T
T Create<T>() where T : new() => new T();
💡 Tip: Add the minimum constraints your code actually needs. Each constraint both enables more inside the method and narrows who can call it — so constrain just enough to do the job, no more.
You've been using generics since Lesson 7 — the entire .NET Base Class Library is built on them.
List<T> // resizable list
Dictionary<TKey,TValue> // key/value map
HashSet<T> // unique set
Queue<T>, Stack<T> // FIFO / LIFO
Nullable<T> (a.k.a. T?) // a value type that can be null
Task<T> // an async result (Lesson 19)
IEnumerable<T> // anything you can foreach over
Func<T,TResult> // a function value (next lesson)
Because these are generic, they're type-safe without duplication. Dictionary<string, List<Order>> composes them freely — a map from strings to lists of orders, all checked at compile time.
var ordersByCustomer = new Dictionary<string, List<Order>>();
ordersByCustomer["ada"] = [new Order(), new Order()];
List<Order> adas = ordersByCustomer["ada"]; // fully typed
Understanding generics means you can read and use any of these confidently — and design your own reusable, type-safe components the same way the framework does.
💡 Tip:
IEnumerable<T>is the most important generic interface to know: it represents "a sequence you can iterate." Almost every collection implements it, and it's the foundation of LINQ — coming up next.
💡 Keep coding: Try every snippet in your own
dotnetproject. Reading teaches you what — typing teaches you how.
Delegates, Lambdas and Events
A delegate is a type that holds a reference to a method — a "method-shaped variable". It lets you pass behaviour around just like data.
// Declare a delegate type: takes two ints, returns an int
delegate int Operation(int a, int b);
int Add(int a, int b) => a + b;
int Multiply(int a, int b) => a * b;
Operation op = Add; // store a method in a variable
Console.WriteLine(op(3, 4)); // 7 — calls Add
op = Multiply; // point it at a different method
Console.WriteLine(op(3, 4)); // 12
Because a delegate is a value, you can pass it to a method — enabling code that's customized by the behaviour you hand in:
int Apply(int x, int y, Operation op) => op(x, y);
Apply(5, 2, Add); // 7
Apply(5, 2, Multiply); // 10
💡 Tip: Delegates are the foundation for callbacks, event handlers, and LINQ. The idea — pass a method as an argument — unlocks a flexible, functional style of programming.
You rarely need to declare delegate types yourself — .NET provides built-in generic ones that cover almost every case.
Func<...>— returns a value; the last type parameter is the return type.Action<...>— returnsvoid(performs an action).Predicate<T>— returnsbool(a test).
Func<int, int, int> add = (a, b) => a + b; // (int, int) -> int
Func<string, int> len = s => s.Length; // string -> int
Action<string> greet = name => Console.WriteLine($"Hi {name}");
Predicate<int> isEven = n => n % 2 == 0;
int sum = add(3, 4); // 7
greet("Ada"); // Hi Ada
bool yes = isEven(10); // true
These types are everywhere in modern C#. For example, List<T> methods take them directly:
List<int> nums = [1, 2, 3, 4, 5];
List<int> evens = nums.FindAll(n => n % 2 == 0); // [2, 4]
nums.ForEach(n => Console.Write($"{n} "));
💡 Tip: Memory aid:
Funcreturns something (think function → result),Actionacts and returns nothing,Predicateasks a yes/no question.
A lambda is an inline, anonymous function — the most common way to supply a delegate. The => reads as "goes to".
x => x * x // one parameter, returns x squared
(a, b) => a + b // two parameters
() => Console.WriteLine("Hi") // no parameters
n => // multi-statement: use braces + return
{
int doubled = n * 2;
return doubled + 1;
}
Lambdas capture variables from their surrounding scope (a closure):
int factor = 10;
Func<int, int> scale = x => x * factor; // captures 'factor'
Console.WriteLine(scale(5)); // 50
Modern lambda features
C# keeps improving lambdas. You can give parameters default values (C# 12) and apply modifiers like ref (C# 14):
var greet = (string name = "World") => $"Hello, {name}!";
greet(); // Hello, World!
greet("Ada"); // Hello, Ada!
// C# 14: ref/in/out/scoped on lambda params without restating the type
var bump = (ref int x) => x++;
💡 Tip: Closures are powerful but capture variables by reference. Capturing a loop variable that keeps changing is a classic gotcha — capture a local copy inside the loop if you need the value frozen.
An event is a delegate-based notification: an object announces "something happened," and any number of subscribers react. It's the observer pattern, built into the language.
class Button
{
public event Action? Clicked; // the event
public void Press()
{
Console.WriteLine("Button pressed");
Clicked?.Invoke(); // notify subscribers (if any)
}
}
Subscribers attach handlers with += and detach with -=:
var button = new Button();
button.Clicked += () => Console.WriteLine("Handler A");
button.Clicked += () => Console.WriteLine("Handler B");
button.Press();
// Button pressed
// Handler A
// Handler B
The publisher (Button) knows nothing about its subscribers — it just raises the event. This decoupling is why events power UI frameworks, message systems, and game engines.
⚠️ Warning: Always raise an event with the null-conditional call
Clicked?.Invoke(). If no one has subscribed, the event isnull, and a plainClicked()would throw aNullReferenceException.
The everyday payoff of delegates is callbacks: pass a function to customize how another method behaves. This is exactly how LINQ (next lesson) works.
// A reusable method parameterized by behaviour
List<T> Filter<T>(List<T> items, Predicate<T> keep)
{
List<T> result = [];
foreach (T item in items)
if (keep(item)) result.Add(item);
return result;
}
List<int> nums = [1, 2, 3, 4, 5, 6];
var evens = Filter(nums, n => n % 2 == 0); // [2, 4, 6]
var big = Filter(nums, n => n > 3); // [4, 5, 6]
The same Filter method does completely different jobs depending on the lambda you pass. You configure behaviour at the call site without touching Filter itself.
Callbacks also model "do this when you're done":
void DownloadThen(string url, Action<string> onComplete)
{
string data = Fetch(url); // (pretend) do the work
onComplete(data); // hand the result back
}
DownloadThen("/api/users", data => Console.WriteLine($"Got {data.Length} bytes"));
💡 Tip: When you spot two methods that are identical except for one small piece of logic in the middle, extract that piece into a
Func/Action/Predicateparameter. One flexible method beats many near-duplicates.
💡 Keep coding: Try every snippet in your own
dotnetproject. Reading teaches you what — typing teaches you how.
LINQ
LINQ (Language-Integrated Query) is a unified way to query collections — filter, transform, sort, group, and aggregate data — using readable, composable operators. It works on any IEnumerable<T>: lists, arrays, dictionaries, even database rows and JSON.
using System.Linq; // (included by implicit usings)
List<int> numbers = [5, 3, 8, 1, 9, 2];
var result = numbers
.Where(n => n > 3) // keep numbers > 3 → 5, 8, 9
.OrderBy(n => n) // sort ascending → 5, 8, 9
.Select(n => n * 10); // transform each → 50, 80, 90
Compare with the manual loop version — LINQ replaces a dozen lines of bookkeeping with a clear pipeline that reads like a sentence: "take the numbers, where greater than 3, ordered, times ten."
Each operator takes a lambda (Lesson 15) and returns a new sequence, so you chain them into a pipeline. The data flows top to bottom.
💡 Tip: LINQ is one of C#'s defining features. Once it clicks, you'll express data transformations declaratively — saying what you want, not how to loop for it.
The two most-used operators are Where (filter) and Select (transform/project).
record Product(string Name, decimal Price, string Category);
List<Product> products =
[
new("Mouse", 25m, "Tech"),
new("Desk", 150m, "Office"),
new("Keyboard", 75m, "Tech"),
];
// Where: keep only matching items
var tech = products.Where(p => p.Category == "Tech");
// Select: project each item into something new
var names = products.Select(p => p.Name); // sequence of strings
var views = products.Select(p => new { p.Name, p.Price }); // anonymous objects
Select can reshape data into anything — a single field, a computed value, a new record, or an anonymous type:
var labels = products
.Where(p => p.Price < 100)
.Select(p => $"{p.Name}: {p.Price:C}");
// "Mouse: $25.00", "Keyboard: $75.00"
💡 Tip:
Wherechanges how many items you have;Selectchanges what each item looks like. Almost every LINQ pipeline is some combination of these two.
Ordering
var byPrice = products.OrderBy(p => p.Price); // ascending
var byPriceDesc = products.OrderByDescending(p => p.Price);
// Secondary sort with ThenBy
var sorted = products
.OrderBy(p => p.Category)
.ThenByDescending(p => p.Price);
Grouping
GroupBy splits a sequence into groups keyed by a value — like a pivot:
var groups = products.GroupBy(p => p.Category);
foreach (var group in groups)
{
Console.WriteLine($"{group.Key}:"); // the category
foreach (var p in group) // items in that group
Console.WriteLine($" {p.Name}");
}
// Tech:
// Mouse
// Keyboard
// Office:
// Desk
Group results are perfect for summaries — combine with aggregation:
var countByCategory = products
.GroupBy(p => p.Category)
.Select(g => new { Category = g.Key, Count = g.Count() });
💡 Tip:
GroupByis your tool whenever you think "for each distinct X, ...". Reports, dashboards, and statistics almost always start with a grouping.
Aggregation operators reduce a whole sequence to a single value.
List<int> nums = [5, 3, 8, 1, 9];
int count = nums.Count(); // 5
int sum = nums.Sum(); // 26
double avg = nums.Average(); // 5.2
int max = nums.Max(); // 9
int min = nums.Min(); // 1
Testing and selecting
bool anyBig = nums.Any(n => n > 8); // true — at least one?
bool allPos = nums.All(n => n > 0); // true — every one?
int firstBig = nums.First(n => n > 4); // 5 — first match (throws if none)
int orZero = nums.FirstOrDefault(n => n > 100); // 0 — safe default if none
These compose with Where/Select for expressive one-liners:
decimal techTotal = products
.Where(p => p.Category == "Tech")
.Sum(p => p.Price); // total price of all Tech products
⚠️ Warning:
First,Single,Max, etc. throw on an empty sequence. Prefer the...OrDefaultvariants (FirstOrDefault) when "no match" is a normal, expected outcome.
LINQ comes in two equivalent flavours. You've seen method syntax (chained calls). There's also query syntax, which reads like SQL:
// Query syntax
var result =
from p in products
where p.Price < 100
orderby p.Name
select p.Name;
// The exact same thing in method syntax
var result2 = products
.Where(p => p.Price < 100)
.OrderBy(p => p.Name)
.Select(p => p.Name);
They compile to identical code — it's purely a matter of style. Most C# developers prefer method syntax because it chains naturally and exposes every operator (some, like Count() and Sum(), have no query-syntax keyword).
Query syntax can be more readable for complex joins and multiple from clauses:
var pairs =
from a in listA
from b in listB
where a == b
select (a, b);
💡 Tip: Pick one style per query and stay consistent. Method syntax is the more common default; reach for query syntax when a complex join or grouping reads more clearly that way.
This is the one LINQ behaviour that surprises everyone: most operators are lazy. Building a query doesn't run it — iterating it does.
var query = numbers.Where(n => n > 3); // nothing has executed yet!
numbers.Add(10); // change the source...
foreach (var n in query) // ...query runs NOW, sees the 10
Console.WriteLine(n);
The query is a recipe, re-evaluated every time you enumerate it. That's powerful but has two consequences:
// Pitfall 1: re-enumeration re-runs the whole pipeline
var expensive = data.Where(SlowCheck);
var a = expensive.Count(); // runs SlowCheck for every item
var b = expensive.ToList(); // runs SlowCheck AGAIN
Force execution and cache the result with ToList(), ToArray(), or ToDictionary():
var results = data.Where(SlowCheck).ToList(); // runs once, stored
int n = results.Count; // cheap — no re-run
⚠️ Warning: If you'll use a query's results more than once — or the source might change — materialize it with
ToList(). Otherwise you risk re-running expensive work or getting different results each time.
💡 Keep coding: Try every snippet in your own
dotnetproject. Reading teaches you what — typing teaches you how.
Exception Handling
When something goes wrong at runtime — a missing file, bad input, a network failure — C# throws an exception. Unhandled, it crashes the program. You handle it with try/catch.
try
{
int[] data = [1, 2, 3];
Console.WriteLine(data[10]); // throws IndexOutOfRangeException
}
catch (IndexOutOfRangeException ex)
{
Console.WriteLine($"Oops: {ex.Message}");
}
Code in try runs until something throws; control then jumps to a matching catch. The finally block runs no matter what — exception or not — making it the place for cleanup:
try
{
file = OpenFile();
Process(file);
}
catch (IOException ex)
{
Console.WriteLine($"I/O failed: {ex.Message}");
}
finally
{
file?.Close(); // always runs — success, failure, or early return
}
💡 Tip: An exception is for exceptional situations, not normal control flow. Don't use try/catch to handle expected cases like "user typed a non-number" — that's what
int.TryParseis for.
You throw an exception with throw when your code detects a problem it can't sensibly continue past.
decimal Withdraw(decimal amount, decimal balance)
{
if (amount <= 0)
throw new ArgumentException("Amount must be positive.", nameof(amount));
if (amount > balance)
throw new InvalidOperationException("Insufficient funds.");
return balance - amount;
}
.NET has a rich hierarchy of built-in exception types — pick the one that fits:
| Exception | Use when |
|---|---|
ArgumentException | A parameter has an invalid value |
ArgumentNullException | A required argument is null |
InvalidOperationException | The object is in a wrong state for this call |
FormatException | Text isn't in the expected format |
KeyNotFoundException | A dictionary key is missing |
Modern C# offers concise guard helpers:
ArgumentNullException.ThrowIfNull(customer);
ArgumentOutOfRangeException.ThrowIfNegative(amount);
💡 Tip: Throw the most specific exception that describes the problem, and include a helpful message.
nameof(amount)keeps the parameter name correct even if you rename it later.
When no built-in type captures your domain's failure, define your own by deriving from Exception.
class InsufficientFundsException : Exception
{
public decimal Shortfall { get; }
public InsufficientFundsException(decimal shortfall)
: base($"Short by {shortfall:C}.") // pass a message to the base
{
Shortfall = shortfall;
}
}
Throw and catch it like any other — and carry useful data along:
try
{
account.Withdraw(1000m);
}
catch (InsufficientFundsException ex)
{
Console.WriteLine($"Declined. You need {ex.Shortfall:C} more.");
}
Custom exceptions make your error handling specific and meaningful: callers can catch exactly the failure they care about and react with extra context (here, the Shortfall).
💡 Tip: Name custom exceptions ending in
Exceptionand derive fromException(or a more specific base). Add properties for any structured data the handler might need — don't force callers to parse the message string.
Some objects hold unmanaged resources — file handles, network sockets, database connections — that must be released promptly. Such types implement IDisposable and have a Dispose() method.
The using statement guarantees Dispose() is called, even if an exception is thrown:
using (var file = new StreamReader("data.txt"))
{
string content = file.ReadToEnd();
Console.WriteLine(content);
} // file.Dispose() called automatically here
Modern C# offers a cleaner using declaration — no braces; disposal happens at the end of the enclosing scope:
void ReadFile()
{
using var file = new StreamReader("data.txt");
Console.WriteLine(file.ReadToEnd());
} // file disposed here, automatically
This is far safer than manual try/finally { file.Close(); } — you can't forget it, and it survives early returns and exceptions.
💡 Tip: Any time you create something that wraps a file, stream, connection, or similar resource, reach for
using. If a type implementsIDisposable, that's its way of telling you "dispose me."
Good exception handling is mostly about restraint. A few guidelines separate robust code from fragile code.
Catch specific, not everything. Catching Exception (or worse, swallowing it silently) hides bugs:
// ❌ Hides every problem, including ones you should fix
try { Risky(); } catch { }
// ✅ Handle what you can meaningfully handle
try { Risky(); }
catch (TimeoutException ex) { Retry(); }
Don't use exceptions for normal flow. Validate first; reserve exceptions for the truly unexpected.
Add context when re-throwing. Use throw; (not throw ex;) to preserve the original stack trace, or wrap with an inner exception:
catch (SqlException ex)
{
throw new DataAccessException("Failed to load user.", ex); // keeps the cause
}
Exception filters let you catch conditionally with when:
catch (HttpRequestException ex) when (ex.StatusCode == 503)
{
// only handles 503s; other statuses propagate
}
⚠️ Warning: Never write an empty
catch { }. Silently swallowing exceptions turns a clear crash into a silent, much harder-to-find bug. At minimum, log it.
💡 Keep coding: Try every snippet in your own
dotnetproject. Reading teaches you what — typing teaches you how.
Null Safety
null means "no object here." Calling a member on null throws the infamous NullReferenceException — historically the most common .NET crash. Modern C# fights back with nullable reference types (NRT), enabled by <Nullable>enable</Nullable> in your .csproj (on by default in new projects).
With NRT on, the type itself says whether null is allowed:
string name = "Ada"; // non-nullable — must never be null
string? maybe = null; // nullable — null is allowed (note the ?)
name = null; // ⚠️ compiler warning — name shouldn't be null
The compiler then tracks nullability and warns you about unsafe access:
void Print(string? text)
{
Console.WriteLine(text.Length); // ⚠️ warning: text may be null
if (text != null)
Console.WriteLine(text.Length); // ✅ fine — checked first
}
💡 Tip: Treat nullable warnings as errors. They're the compiler pointing at a potential
NullReferenceExceptionbefore it happens — exactly the kind of bug you want caught at build time, not in production.
C# has a family of operators that make working with possibly-null values concise and safe.
?. — null-conditional access
Call a member only if the object isn't null; otherwise the whole expression is null:
string? name = GetName();
int? length = name?.Length; // null if name is null — no exception
string upper = name?.ToUpper() ?? "(none)";
?? — null-coalescing
Provide a fallback for null:
string display = name ?? "Anonymous";
??= — null-coalescing assignment
Assign only if the target is currently null:
List<string>? items = null;
items ??= []; // items is now an empty list
items.Add("first");
Chained together, they replace piles of if (x != null) checks:
int count = customer?.Orders?.Count ?? 0;
// If customer or Orders is null, count is 0 — safely, in one line.
💡 Tip: Read
?.as "if not null, then..." and??as "...otherwise use this." The comboa?.b ?? fallbackis one of the most useful idioms in everyday C#.
Until recently, ?. only worked when reading a value. C# 14 extends it to the left-hand side of an assignment — so you can assign through a possibly-null reference safely.
class Customer { public string? Name { get; set; } }
Customer? customer = GetCustomer();
// C# 14: assign only if 'customer' is not null
customer?.Name = "Ada";
If customer is null, the assignment is simply skipped — and, importantly, the right-hand side is not evaluated. Before C# 14 you had to write:
// The old way
if (customer is not null)
customer.Name = ComputeName();
// C# 14 — equivalent, and ComputeName() is skipped when customer is null
customer?.Name = ComputeName();
It also works with indexers and combines with compound assignment:
list?[0] = 42; // assign only if list isn't null
order?.Items ??= []; // initialize Items only if order isn't null
💡 Tip: Null-conditional assignment removes a lot of small
if (x != null)wrappers around assignments. Like?.for reads, the key rule is: if the left side is null, nothing happens — including evaluating the right side.
Value types like int and bool can't normally be null — but sometimes "no value" is meaningful (a missing measurement, an unanswered question). Append ? to make a nullable value type:
int? age = null; // shorthand for Nullable<int>
age = 36;
bool? agreed = null; // tri-state: true / false / unknown
Check and read safely:
if (age.HasValue)
Console.WriteLine(age.Value); // 36
int actual = age ?? 0; // 0 if null
int doubled = age.GetValueOrDefault() * 2;
This is exactly how databases model nullable columns — an int? maps to a SQL INT NULL. It cleanly distinguishes "zero" from "no value at all":
int? score = GetScore();
string msg = score switch
{
null => "Not attempted",
0 => "Scored zero",
> 0 => $"Scored {score}",
};
💡 Tip: Use a nullable value type when "absent" is genuinely different from a default. A nullable
DateTime?for "completed date" cleanly says "not completed yet" rather than abusing some sentinel date.
Null safety is a mindset: validate at boundaries, then trust your types inside.
Guard public entry points
Check arguments where data enters your code, and fail fast with a clear message:
public void Register(string email, User user)
{
ArgumentException.ThrowIfNullOrWhiteSpace(email);
ArgumentNullException.ThrowIfNull(user);
// Below this line, email and user are guaranteed non-null.
}
Prefer non-null defaults
Initialize collections and strings so they're never null in the first place:
public List<string> Tags { get; init; } = []; // empty, not null
public string Notes { get; init; } = "";
An empty list is almost always nicer to work with than a null one — callers can foreach it without checking.
Return empty, not null
// ❌ forces every caller to null-check
List<Order>? FindOrders() => found ? list : null;
// ✅ callers can always iterate safely
List<Order> FindOrders() => found ? list : [];
💡 Tip: A reliable pattern: validate inputs at the edges, default to empty/non-null inside. Combined with nullable reference types, this all but eliminates
NullReferenceExceptionfrom your code.
💡 Keep coding: Try every snippet in your own
dotnetproject. Reading teaches you what — typing teaches you how.
Asynchronous Programming
Many operations spend most of their time waiting — for a web response, a database query, a file read. If your program blocks (sits idle) during that wait, it wastes time and freezes the UI or ties up a server thread.
Asynchronous code lets a thread do other work while waiting, then resume when the result arrives.
// ❌ Synchronous: the thread is stuck for 2 seconds doing nothing
string data = DownloadSync(url); // blocks here
// ✅ Asynchronous: the thread is freed during the wait
string data = await DownloadAsync(url);
The payoff differs by app type:
- Apps with a UI stay responsive — the interface doesn't freeze while loading.
- Servers handle far more requests — threads aren't wasted sitting on I/O.
💡 Tip: Async is about waiting efficiently, not "going faster" by itself. It shines for I/O-bound work (network, disk, database) — the waiting-heavy operations that dominate real applications.
Two keywords drive it all. Mark a method async, and inside it use await to pause until an asynchronous operation completes — without blocking the thread.
async Task<string> GetGreetingAsync()
{
await Task.Delay(1000); // wait 1s without blocking
return "Hello after a delay!";
}
// Calling it:
string message = await GetGreetingAsync();
Console.WriteLine(message);
await is the key idea: it says "pause here until this finishes, freeing the thread meanwhile, then continue with the result." The code reads top-to-bottom like normal synchronous code — the compiler handles the complex machinery.
C# even lets your program's entry point be async:
// Top-level program — await works directly
Console.WriteLine("Starting...");
string msg = await GetGreetingAsync();
Console.WriteLine(msg);
💡 Tip:
awaitdoesn't create threads — it yields the current one until the awaited work is done. That's why thousands of async operations can run on a small thread pool.
An async method returns a Task — a promise of future completion.
Task— completes, but produces no value (the async equivalent ofvoid).Task<T>— completes and produces a value of typeT.
async Task SaveAsync(string data) // no result
{
await File.WriteAllTextAsync("out.txt", data);
}
async Task<int> CountLinesAsync(string path) // returns an int
{
string[] lines = await File.ReadAllLinesAsync(path);
return lines.Length;
}
await on a Task<T> unwraps the value; await on a Task just waits:
await SaveAsync("hello"); // wait for completion
int count = await CountLinesAsync("out.txt"); // wait and get the int
A Task can be in flight before you await it — start now, await later:
Task<int> work = CountLinesAsync("big.txt"); // starts running
DoSomethingElse(); // meanwhile...
int n = await work; // now collect the result
💡 Tip: Async methods should return
TaskorTask<T>and, by convention, end inAsync(LoadAsync,SaveAsync). It signals to callers "this is awaitable" at a glance.
Awaiting tasks one after another is sequential. To run independent operations concurrently, start them all, then await together with Task.WhenAll.
// ❌ Sequential — total time = sum of all three
var a = await FetchAsync("/a");
var b = await FetchAsync("/b");
var c = await FetchAsync("/c");
// ✅ Concurrent — total time ≈ the slowest one
Task<string> ta = FetchAsync("/a");
Task<string> tb = FetchAsync("/b");
Task<string> tc = FetchAsync("/c");
string[] results = await Task.WhenAll(ta, tb, tc);
Task.WhenAll waits for every task and returns all results. Its sibling Task.WhenAny completes as soon as the first task finishes — handy for timeouts or "fastest wins":
var winner = await Task.WhenAny(primary, backup);
For CPU-bound work (heavy computation, not waiting), offload to a background thread with Task.Run:
int result = await Task.Run(() => ExpensiveCalculation());
💡 Tip: When several async operations don't depend on each other — three API calls, a batch of file reads — fire them off together and
Task.WhenAll. It can turn seconds of sequential waiting into a single concurrent wait.
Long-running async work should be cancellable — a user clicks "stop," a request times out. The standard mechanism is a CancellationToken.
async Task ProcessAsync(CancellationToken token)
{
for (int i = 0; i < 1000; i++)
{
token.ThrowIfCancellationRequested(); // bail out if cancelled
await DoStepAsync(i, token); // pass it down the chain
}
}
A CancellationTokenSource produces the token and triggers cancellation:
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(5)); // auto-cancel after 5s
try
{
await ProcessAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Cancelled.");
}
Most built-in async APIs accept a token — always thread it through so cancellation reaches the actual waiting operation.
💡 Tip: Accept a
CancellationTokenparameter in your own async methods and pass it to every async call inside. Cooperative cancellation only works if the token actually reaches the operation that's waiting.
Async is powerful but has sharp edges. Avoid these classic mistakes.
Don't block on async code
Calling .Result or .Wait() on a Task can deadlock and defeats the purpose of async:
// ❌ Can deadlock; blocks the thread
string data = GetDataAsync().Result;
// ✅ Await it
string data = await GetDataAsync();
Avoid async void
async void methods can't be awaited and their exceptions can crash the app. The only valid use is event handlers.
async void Bad() { await Task.Delay(1); } // ❌ avoid
async Task Good() { await Task.Delay(1); } // ✅ return Task
Async all the way
Once you go async, stay async up the call chain. Mixing blocking and async code is where deadlocks and confusion live.
⚠️ Warning: The golden rules: never use
.Result/.Wait()to block on async; never writeasync void(except event handlers); and returnTask/Task<T>so callers canawaityou. Follow these three and most async bugs simply never appear.
💡 Keep coding: Try every snippet in your own
dotnetproject. Reading teaches you what — typing teaches you how.
Files, JSON and the Outside World
The System.IO.File class makes everyday file work a one-liner. Async versions (preferred, Lesson 19) keep your app responsive.
// Write
await File.WriteAllTextAsync("notes.txt", "Hello, file!");
await File.WriteAllLinesAsync("list.txt", ["one", "two", "three"]);
// Read
string text = await File.ReadAllTextAsync("notes.txt");
string[] lines = await File.ReadAllLinesAsync("list.txt");
// Append
await File.AppendAllTextAsync("log.txt", "new entry\n");
// Existence & deletion
if (File.Exists("notes.txt")) File.Delete("notes.txt");
For large files, stream instead of loading everything into memory at once:
using var reader = new StreamReader("huge.txt");
string? line;
while ((line = await reader.ReadLineAsync()) is not null)
{
Process(line); // one line at a time, low memory
}
Build paths portably with Path, which uses the correct separator per OS:
string path = Path.Combine("data", "users", "ada.json");
💡 Tip: Prefer the
...Asyncfile methods andPath.Combineover hand-built strings like"data/" + name. You get responsiveness for free and paths that work on Windows, macOS, and Linux alike.
JSON is the lingua franca of data exchange. .NET's built-in System.Text.Json turns objects into JSON (serialize) and back (deserialize) — fast and with no extra packages.
using System.Text.Json;
record Person(string Name, int Age, string[] Hobbies);
var ada = new Person("Ada", 36, ["math", "music"]);
string json = JsonSerializer.Serialize(ada);
// {"Name":"Ada","Age":36,"Hobbies":["math","music"]}
For human-readable output, enable indentation with options:
var options = new JsonSerializerOptions { WriteIndented = true };
string pretty = JsonSerializer.Serialize(ada, options);
/*
{
"Name": "Ada",
"Age": 36,
"Hobbies": [ "math", "music" ]
}
*/
Serializing works on whole collections too:
List<Person> people = [ada, new("Grace", 85, ["compilers"])];
string arrayJson = JsonSerializer.Serialize(people, options);
💡 Tip:
System.Text.Jsonis built into .NET — no NuGet package needed. It's fast and secure by default, and it's what ASP.NET Core uses under the hood for web APIs.
Going the other way — text into objects — uses Deserialize with the target type.
string json = """
{ "Name": "Grace", "Age": 85, "Hobbies": ["compilers"] }
""";
Person? person = JsonSerializer.Deserialize<Person>(json);
Console.WriteLine(person?.Name); // Grace
The result is nullable (the JSON could be null or invalid), so handle that:
Person p = JsonSerializer.Deserialize<Person>(json)
?? throw new InvalidOperationException("Bad JSON");
Round-trip a file in two lines — read text, deserialize:
string text = await File.ReadAllTextAsync("people.json");
List<Person> people = JsonSerializer.Deserialize<List<Person>>(text) ?? [];
JSON property names won't always match your C# casing. By default matching is case-sensitive; opt into flexibility with options (next section).
⚠️ Warning: Deserialization can fail — malformed JSON throws
JsonException, and missing data yields nulls/defaults. Treat incoming JSON as untrusted: deserialize inside a try/catch and validate the result before using it.
JsonSerializerOptions tailors how JSON maps to your types. The common ones:
var options = new JsonSerializerOptions
{
WriteIndented = true, // pretty output
PropertyNameCaseInsensitive = true, // match Name to "name"
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, // C# Name -> "name"
DefaultIgnoreCondition =
System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
};
Fine-tune individual members with attributes:
using System.Text.Json.Serialization;
record User(
[property: JsonPropertyName("user_name")] string Name,
[property: JsonIgnore] string Password);
Source-generated serialization
For top performance and Native AOT compatibility, .NET can generate serialization code at compile time instead of using runtime reflection:
[JsonSerializable(typeof(Person))]
partial class AppJsonContext : JsonSerializerContext { }
string json = JsonSerializer.Serialize(ada, AppJsonContext.Default.Person);
💡 Tip: Create one
JsonSerializerOptionsinstance and reuse it — constructing a fresh one per call is wasteful. For high-throughput or AOT apps, the source-generated context is the modern, fastest path.
HttpClient calls web APIs over HTTP. With System.Text.Json extensions you can fetch and parse JSON in a single call.
using System.Net.Http.Json;
var http = new HttpClient { BaseAddress = new Uri("https://api.example.com") };
// GET and deserialize in one step
Person? person = await http.GetFromJsonAsync<Person>("/people/1");
// POST an object as JSON
var created = await http.PostAsJsonAsync("/people", new Person("Ada", 36, []));
created.EnsureSuccessStatusCode();
Lower-level access gives you full control over the response:
HttpResponseMessage resp = await http.GetAsync("/status");
if (resp.IsSuccessStatusCode)
{
string body = await resp.Content.ReadAsStringAsync();
}
⚠️ Warning: Don't create a
new HttpClient()per request — it can exhaust network connections. Reuse a single instance (or useIHttpClientFactoryin larger apps). One sharedHttpClientfor the app's lifetime is the standard guidance.
Let's combine files, JSON, and async into a tiny persistence layer — load and save a list of records to disk. This is exactly the pattern you'll use in the capstone.
using System.Text.Json;
record Note(int Id, string Text, bool Done);
class NoteStore(string path)
{
private static readonly JsonSerializerOptions Options =
new() { WriteIndented = true };
public async Task<List<Note>> LoadAsync()
{
if (!File.Exists(path)) return []; // first run: empty
string json = await File.ReadAllTextAsync(path);
return JsonSerializer.Deserialize<List<Note>>(json) ?? [];
}
public async Task SaveAsync(List<Note> notes)
{
string json = JsonSerializer.Serialize(notes, Options);
await File.WriteAllTextAsync(path, json);
}
}
var store = new NoteStore("notes.json");
var notes = await store.LoadAsync();
notes.Add(new Note(notes.Count + 1, "Learn C#", false));
await store.SaveAsync(notes);
💡 Tip: This load/modify/save pattern — backed by JSON on disk — is enough to give small apps real persistence with zero database setup. You now have every piece needed for the capstone project.
💡 Keep coding: Try every snippet in your own
dotnetproject. Reading teaches you what — typing teaches you how.
Modern C# 14 and Extension Members
Extension methods let you add new methods to a type you don't own — string, int, List<T>, even types from a library — as if they were built in. The classic syntax uses a this modifier on the first parameter of a static method in a static class.
public static class StringExtensions
{
public static bool IsBlank(this string s) =>
string.IsNullOrWhiteSpace(s);
public static string Truncate(this string s, int max) =>
s.Length <= max ? s : s[..max] + "…";
}
Now every string has these methods:
" ".IsBlank(); // True
"Hello, world".Truncate(5); // "Hello…"
This is exactly how LINQ works — Where, Select, and friends are extension methods on IEnumerable<T>. Extensions keep call sites fluent and readable.
💡 Tip: Use extension methods to add convenience helpers to existing types without subclassing or wrapping them. They're discovered via
usingthe namespace they live in — the same way LINQ's operators light up onceSystem.Linqis in scope.
The classic syntax only allows extension methods. C# 14 generalizes this into extension members — a new extension block that can also declare extension properties and static members. You name the receiver once for the whole block.
public static class StringExtensions
{
extension(string source) // 'source' is the receiver
{
// extension property
public bool IsBlank => string.IsNullOrWhiteSpace(source);
// extension method
public string Truncate(int max) =>
source.Length <= max ? source : source[..max] + "…";
}
}
Now you can write a property, not just a method — something the old syntax never allowed:
if (name.IsBlank) { ... } // a property on string!
name.Truncate(10); // a method, same block
It works with generics too:
public static class EnumerableExtensions
{
extension<T>(IEnumerable<T> source)
{
public bool IsEmpty => !source.Any();
public T? SecondOrDefault() => source.Skip(1).FirstOrDefault();
}
}
List<int> nums = [];
bool empty = nums.IsEmpty; // True — an extension property
💡 Tip: Extension members are one of C# 14's headline features. The old
this-parameter methods still work everywhere; the newextensionblock adds properties and static members and reads more cleanly when you have several extensions for one type.
C# lets your own types define what operators like + mean — operator overloading. It's perfect for value types such as vectors or money.
readonly record struct Vector(double X, double Y)
{
public static Vector operator +(Vector a, Vector b) =>
new(a.X + b.X, a.Y + b.Y);
public static Vector operator *(Vector v, double k) =>
new(v.X * k, v.Y * k);
}
var sum = new Vector(1, 2) + new Vector(3, 4); // (4, 6)
var scaled = new Vector(1, 2) * 3; // (3, 6)
C# 14: user-defined compound assignment
Previously, a += b was always derived from + (creating a new object). C# 14 lets a type define compound operators like += directly as instance members, so they can update the value in place — more efficient for larger types.
public class Accumulator
{
public int Total { get; private set; }
public void operator +=(int amount) => Total += amount; // C# 14
}
var acc = new Accumulator();
acc += 5; // calls the user-defined += ; Total is now 5
acc += 3; // Total is now 8
💡 Tip: Overload operators only when the meaning is obvious —
+for adding vectors, yes; for unrelated concepts, no. Surprising operators hurt readability far more than a plainly named method ever would.
Two modern features you've met deserve a consolidated look, because they represent how current C# writes clean, efficient code.
Collection expressions everywhere
The [ ... ] syntax (Lesson 7) initializes any collection type and composes with the .. spread:
int[] a = [1, 2, 3];
List<int> b = [..a, 4, 5]; // spread + extend
HashSet<int> c = [1, 1, 2, 3]; // {1, 2, 3}
Span<int> d = [10, 20, 30];
int[][] grid = [[1, 2], [3, 4]]; // nested
Spans for zero-copy work
A Span<T>/ReadOnlySpan<T> is a view over memory with no allocation (Lesson 3). C# 14 improved the implicit conversions, so arrays flow into span-accepting APIs seamlessly:
void Sum(ReadOnlySpan<int> values) { /* ... */ }
int[] data = [1, 2, 3, 4];
Sum(data); // array → ReadOnlySpan<int>, implicitly (C# 14)
Sum([5, 6, 7]); // collection expression → span, directly
Together they let you write expressive, allocation-aware code — the collection expression for readability, the span for performance — without ceremony.
💡 Tip: Default to collection expressions for building collections; they're the idiomatic modern style. Reach for spans in hot paths where avoiding allocations measurably matters — and let C# 14's implicit conversions keep the call sites tidy.
A grab-bag of modern conveniences that polish everyday code.
nameof with unbound generics (C# 14)
nameof now accepts open generic types, returning the bare name:
string a = nameof(List<>); // "List" (C# 14)
string b = nameof(Dictionary<,>); // "Dictionary" (C# 14)
Raw & interpolated strings (recap)
string name = "Ada";
string json = $$"""
{ "user": "{{name}}", "active": true }
""";
// {{ }} inserts values; literal braces stay literal
Target-typed new
When the type is known from context, drop the repetition:
Dictionary<string, List<int>> map = new(); // no need to repeat the type
Person p = new("Ada", 36);
Global usings
Put common usings in one place so they apply project-wide:
// GlobalUsings.cs
global using System.Text.Json;
global using System.Collections.Generic;
💡 Tip: These small features compound. Target-typed
new, global usings, raw strings, and collection expressions together strip away noise so the intent of your code stands out — that's the whole spirit of modern C#.
You now know the language. Idiomatic C# is about choosing well among its features. A practical checklist:
- Records for data, classes for behaviour. Immutable
records for DTOs and values;classes for entities with identity. - Expression-bodied members for one-liners:
public string Name => ...;. - Pattern matching & switch expressions over long
if/elsechains. - LINQ for data transformations instead of manual loops — when it stays readable.
- Nullable reference types on, and treat warnings as errors.
async/awaitall the way for I/O; never block with.Result.- Collection expressions (
[...]) as the default for building collections. - Small methods, good names. A well-named method beats a comment.
// Putting the style together
record Customer(string Name, decimal Balance);
string Tier(Customer c) => c.Balance switch
{
>= 10_000 => "Gold",
>= 1_000 => "Silver",
_ => "Bronze",
};
var vips = customers
.Where(c => c.Balance > 5_000)
.OrderByDescending(c => c.Balance)
.Select(c => c.Name)
.ToList();
💡 Tip: Idiomatic code is code your teammates can read at a glance. Favour clarity over cleverness: reach for the modern feature when it makes intent clearer, not merely shorter. Now let's build something real in the capstone!
💡 Keep coding: Try every snippet in your own
dotnetproject. Reading teaches you what — typing teaches you how.
Capstone: Console Task Manager
Time to build something real. We'll create a command-line task manager that stores tasks, marks them done, filters them, and saves to disk as JSON. It pulls together nearly everything from the course.
Features
- Add a task with a title and priority.
- List tasks, optionally filtered (e.g. only pending, or high priority).
- Complete and remove tasks by id.
- Persist to a JSON file so tasks survive between runs.
Which concepts we'll use
| Part of the app | Concepts |
|---|---|
| Model | record, enum, immutability (L8, L13) |
| Store | List<T>, LINQ, exceptions (L7, L16, L17) |
| Persistence | System.Text.Json, files, async (L19, L20) |
| Commands | pattern matching, list patterns (L8) |
| Loop | control flow, string handling (L3–L5) |
The shape of it
TaskManager/
├── Program.cs # the REPL loop + command dispatch
├── TaskItem.cs # the data model (record + enum)
└── TaskStore.cs # in-memory list + JSON persistence
💡 Tip: Real projects start with a plan, not code. Decide what it does and how it's structured first — the implementation then falls into place one small piece at a time, exactly as we'll do here.
Start with the data. A task is pure data compared by value — a perfect record. Priority is a fixed set — a perfect enum.
// TaskItem.cs
enum Priority { Low, Medium, High }
record TaskItem(int Id, string Title, Priority Priority, bool Done)
{
// A computed property for display — derived, never stored
public string Display =>
$"[{(Done ? "x" : " ")}] #{Id} ({Priority}) {Title}";
}
Because it's a record, we get value equality, a readable ToString, and — crucially — with for non-destructive updates. Marking a task done becomes a clean, immutable transformation:
var task = new TaskItem(1, "Learn C#", Priority.High, Done: false);
var completed = task with { Done = true }; // a new, completed copy
Console.WriteLine(completed.Display);
// [x] #1 (High) Learn C#
No mutation, no setters — just describe the change and get a new value. The original task is untouched, which keeps state changes explicit and predictable.
💡 Tip: Designing the model first — and making it immutable — pays off throughout. Every later operation becomes "take a task, produce a new task," which is easy to reason about and impossible to corrupt by accident.
The store holds the tasks and owns all the rules. It exposes intent-revealing methods and uses LINQ for queries. Notice the encapsulation: the list is private, so nothing outside can corrupt it.
// TaskStore.cs
using System.Text.Json;
class TaskStore(string path)
{
private static readonly JsonSerializerOptions JsonOpts =
new() { WriteIndented = true };
private List<TaskItem> tasks = [];
public async Task LoadAsync()
{
if (!File.Exists(path)) return;
string json = await File.ReadAllTextAsync(path);
tasks = JsonSerializer.Deserialize<List<TaskItem>>(json) ?? [];
}
public async Task SaveAsync() =>
await File.WriteAllTextAsync(path,
JsonSerializer.Serialize(tasks, JsonOpts));
public TaskItem Add(string title, Priority priority)
{
int nextId = tasks.Count == 0 ? 1 : tasks.Max(t => t.Id) + 1;
var task = new TaskItem(nextId, title, priority, Done: false);
tasks.Add(task);
return task;
}
public bool Complete(int id)
{
int i = tasks.FindIndex(t => t.Id == id);
if (i < 0) return false;
tasks[i] = tasks[i] with { Done = true }; // immutable update
return true;
}
public bool Remove(int id) => tasks.RemoveAll(t => t.Id == id) > 0;
// LINQ-powered queries
public IEnumerable<TaskItem> All() => tasks.OrderByDescending(t => t.Priority);
public IEnumerable<TaskItem> Pending() => tasks.Where(t => !t.Done);
public int PendingCount => tasks.Count(t => !t.Done);
}
💡 Tip: This class is the single guardian of the task list. Id generation, completion, and removal all live here behind clear methods — so the rest of the app never touches the raw list and can't break its invariants.
Now the REPL (read-eval-print loop): read a line, parse it, act, repeat. List patterns (Lesson 8) make command parsing remarkably clean.
// Program.cs
var store = new TaskStore("tasks.json");
await store.LoadAsync();
Console.WriteLine("Task Manager — type 'help' for commands.");
while (true)
{
Console.Write("> ");
string[] parts = (Console.ReadLine() ?? "")
.Split(' ', StringSplitOptions.RemoveEmptyEntries);
// Match the command shape with list patterns
switch (parts)
{
case ["help"]:
Console.WriteLine("add <title> <low|med|high> | list | done <id> | rm <id> | quit");
break;
case ["add", .. var rest] when rest.Length >= 2:
var priority = ParsePriority(rest[^1]);
string title = string.Join(' ', rest[..^1]);
var added = store.Add(title, priority);
await store.SaveAsync();
Console.WriteLine($"Added {added.Display}");
break;
case ["list"]:
foreach (var t in store.All()) Console.WriteLine(t.Display);
Console.WriteLine($"({store.PendingCount} pending)");
break;
case ["done", var idText] when int.TryParse(idText, out int id):
Console.WriteLine(store.Complete(id) ? "Done!" : "No such task.");
await store.SaveAsync();
break;
case ["rm", var idText] when int.TryParse(idText, out int id):
Console.WriteLine(store.Remove(id) ? "Removed." : "No such task.");
await store.SaveAsync();
break;
case ["quit"] or ["exit"]:
return;
case []:
break; // empty line, ignore
default:
Console.WriteLine("Unknown command. Type 'help'.");
break;
}
}
static Priority ParsePriority(string s) => s.ToLower() switch
{
"high" or "h" => Priority.High,
"med" or "m" or "medium" => Priority.Medium,
_ => Priority.Low,
};
💡 Tip: Look how list patterns plus
whenguards turn messy string parsing into a clear, declarative table of commands. Eachcasestates exactly the shape it handles — this is pattern matching earning its keep.
A working program is the start; a robust one handles the unexpected. A few finishing touches.
Wrap risky operations
Persistence touches the disk, which can fail. Guard it so a bad save doesn't crash the whole session:
try
{
await store.SaveAsync();
}
catch (IOException ex)
{
Console.WriteLine($"Could not save: {ex.Message}");
}
A note on testing
Because the store is isolated from the console, it's easy to unit-test — no UI required. With a test framework like xUnit:
[Fact]
public void Add_AssignsIncrementingIds()
{
var store = new TaskStore("test.json");
var a = store.Add("first", Priority.Low);
var b = store.Add("second", Priority.High);
Assert.Equal(1, a.Id);
Assert.Equal(2, b.Id);
}
That testability is a direct reward for good structure: data in records, rules in the store, I/O at the edges.
Where to take it next
- Add due dates (
DateTime?) and sort by them. - Add a search command using LINQ
Whereon the title. - Swap JSON files for a SQLite database via Entity Framework Core.
- Turn it into a web API with ASP.NET Core — the model and store barely change!
🎉 Congratulations! You've gone from
Hello, C#!to a complete, well-structured, modern C# application. You now have the fundamentals — and the habits — to build real software. Keep going, keep building, and welcome to C#!
💡 Keep coding: Try every snippet in your own
dotnetproject. Reading teaches you what — typing teaches you how.
You Made It! 🎉
You have completed all 22 lessons of Modern C# from Zero to Hero. You went from an empty Program.cs to records, generics, LINQ, async/await, and the newest C# 14 features — and you built a complete console application along the way.
What you learned
Types and variables, control flow, methods, collections, pattern matching, the full OOP toolkit (classes, properties, inheritance, interfaces, records), generics, delegates and LINQ, exception handling, null safety, asynchronous programming, files and JSON, and modern C# 14 syntax.
Where to go next
- ASP.NET Core — build web APIs and full-stack web apps.
- Blazor — interactive web UIs written entirely in C#.
- .NET MAUI — cross-platform desktop and mobile apps.
- Unity / Godot — game development with C#.
- Entity Framework Core — talk to databases the modern way.
Keep learning
- Microsoft Learn (learn.microsoft.com/dotnet) — free, official, hands-on.
- The C# language reference (learn.microsoft.com/dotnet/csharp).
- Read other people's code on GitHub and contribute to open source.
💡 The best way to keep improving is to build. Pick a small project that excites you — a CLI tool, a small game, a web API — and write it in C#. You now have all the fundamentals to do exactly that.
Frequently Asked Questions
What does the C# 14 — Modern C# from Zero to Hero course cover?
The course covers modern C# 14 from zero to a complete console application across 22 lessons in 7 chapters: variables, control flow, collections, the full object-oriented toolkit, generics, LINQ, async/await, null safety, and the newest C# 14 syntax. It ends with a capstone project, a persisted console task manager, and runs about 11 hours in total.
Do I need prior programming experience to start this course?
No. The course is designed for complete beginners and starts from an empty Program.cs, explaining every concept from scratch with runnable examples. Prior programming experience helps you move faster, but the only real requirements are a computer running Windows, macOS, or Linux, the free .NET 10 SDK, and curiosity.
How long does the C# course take to complete?
The course runs about 11 hours across 22 self-paced lessons in 7 chapters, plus 88 quiz questions — four per lesson — to check understanding along the way. Individual lessons run 20 to 40 minutes each and are split into 4-6 tabbed sections, so most lessons fit comfortably into a single sitting.
How do I unlock the next lesson in the course?
Each of the 22 lessons ends with a four-question multiple-choice quiz, and you need to score at least 67% — three of the four questions correct — to unlock the next lesson. Every question includes an explanation, so even a failed attempt reinforces the concept before you retry.
Does the course teach LINQ and other modern C# 14 features?
Yes. A dedicated LINQ lesson covers filtering, projecting, ordering, grouping, aggregation, and the difference between query and method syntax, including the pitfalls of deferred execution. A separate "Modern C# 14 and Extension Members" lesson covers extension members, operator overloading, collection expressions, and spans — the newest syntax the language has to offer.
Does the course cover object-oriented programming in C#?
Yes. Five lessons in the "Object-Oriented C#" chapter build the full OOP toolkit: classes and objects, properties and encapsulation, inheritance and polymorphism, interfaces and abstraction, and structs, records, and immutability. An earlier lesson on enums and pattern matching lays the groundwork just before this chapter begins.
Is there a hands-on project in the course?
Yes. The course ends with a capstone lesson, "Capstone: Console Task Manager," where learners combine records, enums, LINQ, encapsulation, and asynchronous file I/O to build a command-line app that adds, lists, completes, and persists tasks to a JSON file with System.Text.Json — tying together nearly everything taught in the previous 21 lessons.
Do I need to install .NET locally to follow this course?
Yes. Lesson 1, "Hello, C#!", walks through installing the free .NET 10 SDK on Windows, macOS, or Linux and verifying it with dotnet --version, since the course has you type and run every example yourself rather than watch a video. The SDK, a code editor such as VS Code, and a terminal are all you need.