C# & .NET (C# 14, .NET 10 LTS) Module 6 – Classes, objects and encapsulation
Extension methods and C# 14 extension members
Write classic C# extension methods and C# 14 extension blocks with properties, static members and operators, and predict which member a call reaches.
What you will learn
- Write classic extension methods and C# 14 extension blocks
- Add extension properties, static extension members and operators to types you do not own
- Predict which member a call binds to when a type and an extension both offer it, now and after a library update
- Design extensions for structs and for values they cannot store, with ref receivers and the right storage
- Decide when an extension is clearer than a static helper, an instance member or a new type
Before you start
What you’ll learn · Before you start
What you will learn
- Write classic extension methods and C# 14 extension blocks
- Add extension properties, static extension members and operators to types you do not own
- Predict which member a call binds to when a type and an extension both offer it, now and after a library update
- Design extensions for structs and for values they cannot store, with ref receivers and the right storage
- Decide when an extension is clearer than a static helper, an instance member or a new type
Before you start
On this page
"4512398760".Masked() returns ******8760, yet string has no Masked method: an extension declared it from
outside the type. LINQ’s Where, Select and Count reach every array and list the same way, and C# 14 lets
extensions add properties, static members and operators too.
You will write classic extension methods and C# 14 extension blocks, predict which member a call reaches, and decide when an extension is the clearest design.
An extension method is a static method that reads like a member
An extension method: A static method of a static class whose first parameter is marked this, such as WordCount(this string text). Callers write text.WordCount(), and the compiler turns the call into TextExtensions.WordCount(text). is a static method in a static class that is neither nested
nor generic, with this before its first parameter. A using directive for its namespace brings it into scope:
using Rail.Text;
// A classic extension method: a static method whose first parameter carries this, called as if string had it.
var pnr = "4512398760";
Console.WriteLine($"pnr.Masked(): {pnr.Masked()}");
Console.WriteLine($"TextExtensions.Masked(pnr): {TextExtensions.Masked(pnr)}");
// The call is a static method call, so a null receiver arrives as an argument instead of throwing.
string? missing = null;
Console.WriteLine($"missing.IsBlank(): {missing.IsBlank()}");
namespace Rail.Text
{
// Extension methods live in a static class that is not nested and not generic.
public static class TextExtensions
{
// Every character but the last four becomes *, as on a ticket shared in a screenshot.
public static string Masked(this string text) =>
text.Length <= 4 ? text : new string('*', text.Length - 4) + text[^4..];
public static bool IsBlank(this string? text) => string.IsNullOrWhiteSpace(text);
}
} Output
pnr.Masked(): ******8760 TextExtensions.Masked(pnr): ******8760 missing.IsBlank(): True
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run classic.cs
pnr.Masked() and TextExtensions.Masked(pnr) both printed ******8760, because the compiler turns the first into
the second: extension methods are static methods called with instance syntax. That explains the last line: IsBlank
on a null string returned True without an exception, because the string was passed as an argument and no member of
a null object was called.
| You write | The compiler calls |
|---|---|
pnr.Masked() |
TextExtensions.Masked(pnr) |
missing.IsBlank() |
TextExtensions.IsBlank(missing), with null as the argument |
Extension methods arrived in C# 3.0 with LINQ, whose query operators are extension methods on IEnumerable<T>:
using System.Linq is what puts Where on every array.
Checkpoint
Check: Classic extension methods
C# 14 extension blocks add properties, static members and operators
C# 14 extension members are declared in an extension block: A C# 14 declaration, extension(Type receiver) { … }, inside a static class. Its members become extension methods, extension properties and operators of that type; a block that names only the type, extension(Type), declares static extension members., extension(...) { },
inside the same kind of static class. blocks.cs gives sequences of boarding counts three new members and keeps
a classic extension method beside them:
using Rail.Boarding;
// C# 14 extension blocks give sequences of boarding counts an instance property, a static method and an operator,
// next to a classic extension method. Callers use them as if IEnumerable<int> had declared them.
IEnumerable<int> morning = [5, 3, 8];
IEnumerable<int> evening = [2, 6, 1];
Console.WriteLine($"morning.Total: {morning.Total}, evening.Total: {evening.Total}");
Console.WriteLine($"IEnumerable<int>.Zeros(3): {string.Join(", ", IEnumerable<int>.Zeros(3))}");
Console.WriteLine($"morning + evening: {string.Join(", ", morning + evening)}");
Console.WriteLine($"morning.Busiest(): {morning.Busiest()}");
namespace Rail.Boarding
{
public static class BoardingExtensions
{
// A receiver with a name: instance members, which read counts.
extension(IEnumerable<int> counts)
{
public int Total => counts.Sum();
}
// A receiver type only: static members and operators of IEnumerable<int>.
extension(IEnumerable<int>)
{
public static IEnumerable<int> Zeros(int stations) => Enumerable.Repeat(0, stations);
// Station by station: the boardings of two trains added together.
public static IEnumerable<int> operator +(IEnumerable<int> left, IEnumerable<int> right) =>
left.Zip(right, (a, b) => a + b);
}
// A classic extension method in the same class: the two forms compile to the same kind of static method.
public static int Busiest(this IEnumerable<int> counts) => counts.Max();
}
} Output
morning.Total: 16, evening.Total: 9 IEnumerable<int>.Zeros(3): 0, 0, 0 morning + evening: 7, 9, 9 morning.Busiest(): 8
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run blocks.cs
The first block names its receiver: The value an extension member works on: the this parameter of an extension method, or the named parameter of an extension block, such as source in extension<T>(IEnumerable<T> source)., counts, and declares members that callers reach
through a value: morning.Total was 16 and evening.Total 9. The second block names only the type, so its members
are static members of IEnumerable<int>: IEnumerable<int>.Zeros(3) gave 0, 0, 0, and morning + evening added
the two trains station by station, 7, 9, 9. C# 14 extension members and classic extension methods compile to the
same kind of static method, so Busiest() sits in the same class, and converting one form to the other changes no
call site.
| Declared in | Declares | Callers write |
|---|---|---|
extension(IEnumerable<int> counts) |
instance methods and properties | morning.Total |
extension(IEnumerable<int>) |
static members and operators | IEnumerable<int>.Zeros(3), morning + evening |
static int Busiest(this IEnumerable<int> counts) |
an instance method only | morning.Busiest() |
C# 15 adds extension indexers
From C# 15, says the language reference, a block with a named receiver may also declare indexers, such as
numbers[2]. C# 15 is in preview with .NET 11; this lesson’s examples run on C# 14.
Checkpoint
Check: C# 14 extension blocks
Instance members always win, and extensions see no private members
An extension can add to a type, never replace what the type already has. wins.cs gives Ticket an extension
Describe() with the same signature as its own method, and an extension property that reads the ticket’s fare:
using Rail.Tickets;
// An extension never replaces a member that the type already has, and it reaches only what any other code can.
var ticket = new Ticket("4512398760", 2_150_00);
Console.WriteLine($"ticket.Describe(): {ticket.Describe()}");
Console.WriteLine($"TicketExtensions.Describe(ticket): {TicketExtensions.Describe(ticket)}");
Console.WriteLine($"ticket.FareInRupees: {ticket.FareInRupees}");
namespace Rail.Tickets
{
public sealed class Ticket(string pnr, int farePaise)
{
public int FarePaise => farePaise;
public string Describe() => $"ticket {pnr}";
}
public static class TicketExtensions
{
extension(Ticket ticket)
{
// Same name and parameters as Ticket.Describe, so ticket.Describe() never reaches it.
public string Describe() => $"a ticket of {ticket.FarePaise} paise";
// ticket.pnr would not compile here: an extension sees the public members, as any other code does.
public decimal FareInRupees => ticket.FarePaise / 100m;
}
}
} Output
ticket.Describe(): ticket 4512398760 TicketExtensions.Describe(ticket): a ticket of 215000 paise ticket.FareInRupees: 2150
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run wins.cs
ticket.Describe() printed the instance method’s ticket 4512398760; the extension ran only when called as a static
method. The compiler binds every call while it compiles, and it looks at the type’s own members before any extension:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A call to bind. The compiler meets ticket.Describe() while it compiles Program.cs. It must choose one method for this call before the program ever runs.
- The type first. It searches the members of Ticket. Ticket declares Describe() with no parameters, which fits the call.
- The search ends. A member that fits ends the search, so the extension Describe() is never considered. The run printed ticket 4512398760.
- No member fits. For ticket.FareInRupees, Ticket has no member of that name, so the search goes on to the extensions that using directives bring into scope.
- An extension fits. TicketExtensions declares FareInRupees for a Ticket receiver. The call becomes a static call with the ticket as its argument, and it printed 2150.
- Fixed at compile time. Either way the choice is made once and kept in the compiled code. A member that a later version adds to Ticket changes the choice only when the app is compiled again.
The extension property could read FarePaise because it is public. An extension sees what any other code sees, so
the private parts of Ticket stay out of reach, and it has no storage of its own: FareInRupees worked out its 2150
on every read. Three consequences follow. A library that later adds a member with an extension’s name takes over the
call at the next build, without a warning. A struct receiver arrives as a copy unless the block takes it by ref. And
two imported extensions with the same name make a call ambiguous, error CS0121.
Extensions are one design among several. A static helper method is the plain alternative: it takes the value as
an ordinary argument. A new type, such as a checked bank code, can carry a rule that an extension on string never
can.
Key idea
An extension is a static method that reads like a member: the compiler picks the type’s own member first, and an extension reaches only what other code may reach and stores nothing of its own.
Checkpoint
Check: Members first, no private members
When a library adds the member you extended: compile-time binding, reproduced Pro: opens with a Pro, Premium or Ultimate pass
An extension call is bound to a method when the caller is compiled, and a later version of the extended type can take the call away. The run below upgrades a library under an app, first without and then with a rebuild of the app.
In this part:
- A Friday that becomes a weekend after a rebuild, reproduced
- Guarding against a member that arrives later
3 runnable examples (C#, Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Struct receivers and state an extension cannot hold, measured Pro: opens with a Pro, Premium or Ultimate pass
An extension’s receiver is a parameter, and for a struct a parameter is a copy. An extension also has no fields, so any value it remembers lives somewhere else. Both are measured below.
In this part:
- A gate counter that stays at 0: by-value and ref receivers
- No fields of their own: CS9282, CS0122, and 10,000 notes that never go away
3 runnable examples (C#, Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Choosing between an extension, a helper and a new type, with the ambiguity a shared name causes Pro: opens with a Pro, Premium or Ultimate pass
Extensions are found by namespace, so two libraries that both extend a type can collide in your file. The run below reproduces the collision and its fix, and a table then helps decide when an extension is the right tool at all.
In this part:
- CS0121: two imported extensions with the same name
- A decision you can test against your own case
2 runnable examples (C#, Python) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: India’s tax year as DateOnly extensions
The tax year
The Income Tax Department describes the tax year of the Income-tax Act, 2025 as the twelve months of a financial year, from April to the following March, written as Tax Year 2026-27 for the first year of the new Act.
taxyear.cs checks three C# 14 extension members of DateOnly that you write: IsWeekend, TaxYear and the static
DateOnly.Today(TimeProvider clock), the date in India at the clock’s time. They start out throwing, so all 9
checks fail:
using System.Globalization;
// Practice: give DateOnly three C# 14 extension members. IsWeekend is true on Saturdays and Sundays. TaxYear names
// India's tax year, which runs from April to the following March, as "Tax Year 2026-27". DateOnly.Today(clock)
// returns the date in India, at UTC+05:30, at the clock's time. Run this file until all nine checks pass.
(string Check, Func<string> Run, string Expected)[] checks =
[
("a Saturday", () => new DateOnly(2026, 10, 17).IsWeekend.ToString(), "True"),
("a Monday", () => new DateOnly(2026, 10, 19).IsWeekend.ToString(), "False"),
("the first day of a tax year", () => new DateOnly(2026, 4, 1).TaxYear, "Tax Year 2026-27"),
("the last day of that year", () => new DateOnly(2027, 3, 31).TaxYear, "Tax Year 2026-27"),
("the day after", () => new DateOnly(2027, 4, 1).TaxYear, "Tax Year 2027-28"),
("a leap day", () => new DateOnly(2028, 2, 29).TaxYear, "Tax Year 2027-28"),
("a new century", () => new DateOnly(2099, 12, 1).TaxYear, "Tax Year 2099-00"),
("today from a clock", () => DateOnly.Today(Clock(2025, 11, 14, 6, 30)).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), "2025-11-14"),
("today in India, not in UTC", () => DateOnly.Today(Clock(2027, 3, 31, 20, 0)).TaxYear, "Tax Year 2027-28"),
];
int passed = 0;
foreach (var (check, run, expected) in checks)
{
string got;
try
{
got = run();
}
catch (Exception e)
{
got = $"throws {e.GetType().Name}";
}
bool ok = got == expected;
if (ok)
{
passed++;
}
Console.WriteLine(ok ? $"{check}: ok" : $"{check}: FAIL, {got} instead of {expected}");
}
Console.WriteLine($"{passed} of {checks.Length} checks pass");
return passed == checks.Length ? 0 : 1;
// A clock that always gives this time in UTC.
static TimeProvider Clock(int year, int month, int day, int hour, int minute) =>
new FixedClock(new DateTimeOffset(year, month, day, hour, minute, 0, TimeSpan.Zero));
sealed class FixedClock(DateTimeOffset utcNow) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => utcNow;
}
static class DateExtensions
{
extension(DateOnly date)
{
public bool IsWeekend => throw new NotImplementedException();
public string TaxYear => throw new NotImplementedException();
}
extension(DateOnly)
{
public static DateOnly Today(TimeProvider clock) => throw new NotImplementedException();
}
} Output (exit status 1)
a Saturday: FAIL, throws NotImplementedException instead of True a Monday: FAIL, throws NotImplementedException instead of False the first day of a tax year: FAIL, throws NotImplementedException instead of Tax Year 2026-27 the last day of that year: FAIL, throws NotImplementedException instead of Tax Year 2026-27 the day after: FAIL, throws NotImplementedException instead of Tax Year 2027-28 a leap day: FAIL, throws NotImplementedException instead of Tax Year 2027-28 a new century: FAIL, throws NotImplementedException instead of Tax Year 2099-00 today from a clock: FAIL, throws NotImplementedException instead of 2025-11-14 today in India, not in UTC: FAIL, throws NotImplementedException instead of Tax Year 2027-28 0 of 9 checks pass
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run taxyear.cs
Write the three members
Copy taxyear.cs. IsWeekend tests date.DayOfWeek. TaxYear starts at date.Year from April on and at
date.Year - 1 before it, and writes the end year with two digits, (start + 1) % 100. Today converts
clock.GetUtcNow() to the offset TimeSpan.FromHours(5.5) before DateOnly.FromDateTime. Run
dotnet run taxyear.cs until it prints 9 of 9 checks pass.
Then check yourself:
Practice that adapts to you
Practise this lesson
9 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What is an extension method in C#?
A static method, declared in a static class that is not nested or generic, whose first parameter is marked this,
such as public static string Masked(this string text). Once its namespace is imported, callers write
pnr.Masked() as if string had the method, and the compiler turns the call into TextExtensions.Masked(pnr). It adds behaviour to a type without changing or deriving from it, which is how
LINQ’s Where and Select reach every IEnumerable<T>. It cannot replace a member the type already has, and it never
sees private members. C# 14 extension blocks add extension properties, static extension members and operators.
-
Mid to senior
Your app gives a library's Journey class an IsWeekend extension property. After a package upgrade and a rebuild, with no change to your code, Friday journeys start counting as weekends and every build still reports no warnings. What happened, and how do you fix it and guard against it?
-
Mid to senior
A teammate adds an extension property Customer.DisplayName and caches each result in a static Dictionary<Customer, string> to avoid recomputing it. In a long-running service, memory grows steadily. Review the design and propose a better one.
-
Fresher to mid
Your payments code checks Indian bank branch codes (IFSC). Should the check be an extension property on string, such as code.IsIfsc, a static helper method, or a type Ifsc of its own? Choose one and defend it.
Key takeaways
- An extension method is a static method with
thison its first parameter; the compiler turnsx.M()into a static call, which is why anullreceiver arrives as an argument. - C# 14 extension blocks declare instance members for a named receiver and static members and operators for a receiver type, and they compile to the same static methods as classic extensions.
- The compiler picks the type’s own member before any extension, and an extension sees no private members and stores nothing of its own.
Terms in this lesson
- extension block
- A C# 14 declaration, extension(Type receiver) { … }, inside a static class. Its members become extension methods, extension properties and operators of that type; a block that names only the type, extension(Type), declares static extension members.
- extension method
- A static method of a static class whose first parameter is marked this, such as WordCount(this string text). Callers write text.WordCount(), and the compiler turns the call into TextExtensions.WordCount(text).
- receiver
- The value an extension member works on: the this parameter of an extension method, or the named parameter of an extension block, such as source in extension<T>(IEnumerable<T> source).
References
- Extension members (C# programming guide) (Microsoft Learn)
- Extension member declarations (the extension keyword) (Microsoft Learn)
- Tutorial: explore extension members in C# 14 and C# 15 (Microsoft Learn)
- What's new in C# 14 (extension members) (Microsoft Learn)
- The history of C# (extension methods in C# 3.0) (Microsoft Learn)
- ConditionalWeakTable class (Microsoft Learn)
- TimeProvider class (Microsoft Learn)
- Reserve Bank of India: frequently asked questions on NEFT (the IFSC format) (Reserve Bank of India)
- Income Tax Department: objective and scope of the Income-tax Act, 2025 (tax year) (Income Tax Department, Government of India)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress