MrT Stephens

Using C# 14 Extension Members: Beyond Extension Methods

Published on 26 September 2026

Extension methods have been part of C# since version 3.0. They let you “add” methods to types you don’t own—string, IEnumerable<T>, third-party classes—without inheritance or recompiling the original assembly.

They also came with an obvious limitation: methods only. If you wanted a computed value such as a customer’s full name, you had to ship customer.GetFullName() instead of customer.FullName. If you wanted something that felt like a static member of a type, you couldn’t express it at all. Extension methods were always instance-style calls on a receiver.

C# 14, which ships with .NET 10, removes those restrictions with extension members. The new extension block syntax lets you declare extension properties, static extension members, and even user-defined operators—all grouped together in a single, readable block.

In this article, you’ll see exactly what the new syntax looks like, how it differs from the classic this modifier approach, and where each form is the right tool.

What are extension members?

Extension members are methods, properties, or operators that appear to be members of another type, even though they’re declared in your own static class. The compiler rewrites your call site so a call that looks like an instance call is actually a static method call.

Before C# 14, there was exactly one way to do this: add the this modifier to the first parameter of a static method.

namespace ExtensionsDemo;

public static class StringExtensions
{
    public static int WordCount(this string text) =>
        text.Split([' ', '.', '?', '!'], StringSplitOptions.RemoveEmptyEntries).Length;
}

Calling it feels like a normal string member:

string sentence = "Extension members are genuinely useful";
int words = sentence.WordCount(); // 5

That works fine for methods. But behind the scenes, every extension method is just a static method with a decorated first parameter, so the syntax has always been limited: one method at a time, no properties, no static members, no operators, and no way to group related members together with a shared receiver.

What’s new in C# 14

C# 14 introduces extension blocks. Inside a single block you declare:

  • Instance extension methods—the classic capability, with cleaner syntax.
  • Instance extension properties—read-only or computed values like sequence.IsEmpty.
  • Static extension methods and properties—members that appear on the type rather than on an instance, such as Customer.CreateGuest(...).
  • User-defined operators—including arithmetic and comparison operators that act on the extended type.
  • ref receivers—so you can mutate a struct receiver in place.
  • Generic receivers with constraints—extension<T>(IEnumerable<T> source) where T : IComparable<T>.

Here’s the same WordCount extension rewritten with the new syntax:

namespace ExtensionsDemo;

public static class StringExtensions
{
    extension(string text)
    {
        public int WordCount() =>
            text.Split([' ', '.', '?', '!'], StringSplitOptions.RemoveEmptyEntries).Length;
    }
}

The call site is unchanged—sentence.WordCount() still works. What changed is that the receiver (text) is now declared once, at the top of the block, and every member inside the block can use it. That’s the whole idea: a receiver, then a set of members that share it.

Setting up the project

The new syntax requires C# 14 and the .NET 10 SDK (or Visual Studio 2026). Create a fresh console project:

dotnet new console -n ExtensionMembersDemo
cd ExtensionMembersDemo

Check that the project file targets .NET 10:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

When you target net10.0, the compiler uses C# 14 by default, so no extra configuration is needed. If you must target an older framework and still want the language feature, set the version explicitly—but be aware that extension members are a compile-time feature, so they work as long as the target runtime supports the rest of your code:

<LangVersion>14.0</LangVersion>

Extension blocks work in any top-level, non-generic static class. A common convention is a single static class per extended type, dropped in an Extensions folder and exposed through a namespace you import with using.

Extension properties

The most immediately useful addition is the extension property. Before C# 14, this pattern always needed a method:

public record Customer(string FirstName, string LastName, bool IsGuest);

public static class CustomerExtensions
{
    public static string GetFullName(this Customer customer) =>
        $"{customer.FirstName} {customer.LastName}".Trim();
}

Now, the same member can be a property, which reads far more naturally at the call site:

namespace ExtensionsDemo;

public static class CustomerExtensions
{
    extension(Customer customer)
    {
        // Instance extension property
        public string FullName => $"{customer.FirstName} {customer.LastName}".Trim();

        // Extension properties can be computed from other extension members
        public bool IsAnonymous => customer.IsGuest || string.IsNullOrWhiteSpace(customer.FullName);

        // Extension methods can live alongside properties in the same block
        public string Salutation(string template = "Hi {0}") =>
            string.Format(template, customer.IsAnonymous ? "there" : customer.FullName);
    }
}

Usage:

var customer = new Customer("Ada", "Lovelace", IsGuest: false);

Console.WriteLine(customer.FullName);              // Ada Lovelace
Console.WriteLine(customer.IsAnonymous);           // False
Console.WriteLine(customer.Salutation());          // Hi Ada Lovelace
Console.WriteLine(customer.Salutation("Hello {0}")); // Hello Ada Lovelace

Properties are computed on demand—they don’t cache anything and they don’t participate in equality, serialization, or model binding. They are syntactic conveniences over a static method call, nothing more.

Static extension members

The second big change is that extension members can extend the type itself rather than an instance. To do that, declare an extension block with only a receiver type and no receiver name:

public static class CustomerExtensions
{
    // No receiver name: this block may only contain static members
    extension(Customer)
    {
        public static Customer CreateGuest() => new("Guest", string.Empty, IsGuest: true);

        public static Customer CreateGuest(string displayName) => new(displayName, string.Empty, IsGuest: true);
    }
}

Calling them looks exactly like calling a static member on the type:

var walkIn = Customer.CreateGuest("Walk-in");

Console.WriteLine(walkIn.FullName);   // Walk-in
Console.WriteLine(walkIn.IsAnonymous); // True

This is genuinely new. Previously, a “factory”-style extension had to be called as CustomerExtensions.CreateGuest(), which leaked the helper class into your business code. Now the helper method appears where developers expect to find it.

The same applies to static properties:

public static class OrderCollectionExtensions
{
    extension(IEnumerable<Order>)
    {
        // Static extension property
        public static IEnumerable<Order> Empty => [];
    }
}

public record Order(Guid Id, Guid CustomerId, decimal Total);
IEnumerable<Order> none = IEnumerable<Order>.Empty;

Static extension methods can also be used for generation or parsing helpers, keeping the API surface anchored on the type they produce.

Extension operators

Operators can be declared in an extension block as static members, which means you can define arithmetic or comparison behaviour for types you don’t own:

public static class OrderCollectionExtensions
{
    extension(IEnumerable<Order> orders)
    {
        // Instance extension method
        public IEnumerable<Order> WithTotalOver(decimal amount) =>
            orders.Where(order => order.Total > amount);

        // User-defined operator, exposed on IEnumerable<Order>
        public static IEnumerable<Order> operator +(
            IEnumerable<Order> left,
            IEnumerable<Order> right) => left.Concat(right);
    }
}
IEnumerable<Order> monday =
[
    new Order(Guid.NewGuid(), customerId, 42.50m),
    new Order(Guid.NewGuid(), customerId, 9.99m)
];

IEnumerable<Order> tuesday =
[
    new Order(Guid.NewGuid(), customerId, 150m)
];

// Uses the extension operator
IEnumerable<Order> week = monday + tuesday;

// Uses an instance extension method
IEnumerable<Order> bigOrders = week.WithTotalOver(40m);

Console.WriteLine(week.Count());      // 3
Console.WriteLine(bigOrders.Count()); // 2

The operator only exists when its namespace is imported, and it never overrides an operator the type already defines. If IEnumerable<Order> ever grew a real + operator in a future framework version, the real operator would win—extension members always lose to members declared on the type.

Generic receivers and constraints

Extension blocks support open and closed generics, and constraints go on the extension declaration:

public static class ComparableExtensions
{
    extension<T>(IEnumerable<T> source) where T : IComparable<T>
    {
        public T? MaxOrDefault()
        {
            var hasValue = false;
            T best = default!;

            foreach (var item in source)
            {
                if (!hasValue || item.CompareTo(best) > 0)
                {
                    best = item;
                    hasValue = true;
                }
            }

            return hasValue ? best : default;
        }
    }
}
int[] numbers = [10, 45, 15, 39, 21, 26];
Console.WriteLine(numbers.MaxOrDefault()); // 45

Type parameters follow a simple rule:

  • Put the type parameter on the extension declaration when it appears in the receiver.
  • Put it on the member declaration when it’s specific to that member.
  • Never declare the same type parameter in both places.

That last point matters for members that introduce their own generic argument:

public static class SequenceExtensions
{
    extension<TReceiver>(IEnumerable<TReceiver> source)
    {
        // TArg belongs to this member only; TReceiver comes from the receiver
        public IEnumerable<TReceiver> Append<TArg>(
            IEnumerable<TArg> second,
            Func<TArg, TReceiver> converter)
        {
            foreach (TReceiver item in source)
            {
                yield return item;
            }

            foreach (TArg item in second)
            {
                yield return converter(item);
            }
        }
    }
}

ref receivers for structs

Value types are passed by value, so a normal extension method works on a copy. To mutate the original, declare the receiver as ref:

public struct Wallet
{
    public decimal Balance { get; set; }
}

public static class WalletExtensions
{
    extension(ref Wallet wallet)
    {
        public void Deposit(decimal amount) => wallet.Balance += amount;

        public void Withdraw(decimal amount) =>
            wallet.Balance -= Math.Min(amount, wallet.Balance);
    }
}
var wallet = new Wallet { Balance = 100m };

wallet.Deposit(50m);   // mutates the original struct
wallet.Withdraw(30m);

Console.WriteLine(wallet.Balance); // 120

A ref receiver requires a separate block from a by-value receiver, because the receiver’s parameter mode is part of the block’s identity:

public static class IntExtensions
{
    extension(int number)
    {
        // Works on a copy: the caller's variable is unchanged
        public int Incremented() => number + 1;
    }

    extension(ref int number)
    {
        // Mutates the caller's variable
        public void Increment() => number++;
    }
}

Only value types (or generic types constrained to struct) can be ref receivers. As with every extension member, you still can’t reach private members of the extended type.

Putting it together: a support ticket domain

Here’s a small example showing all three member kinds working together on a single domain type.

namespace ExtensionsDemo;

public record Ticket(
    Guid Id,
    string Subject,
    string Body,
    TicketStatus Status,
    DateTimeOffset CreatedAt,
    DateTimeOffset? ResolvedAt);

public enum TicketStatus
{
    Open,
    Pending,
    Resolved
}

public static class TicketExtensions
{
    // Instance extensions: things you ask a ticket
    extension(Ticket ticket)
    {
        public bool IsOverdue =>
            ticket.Status != TicketStatus.Resolved && ticket.CreatedAt < DateTimeOffset.UtcNow.AddDays(-7);

        public TimeSpan? ResolutionTime =>
            ticket.ResolvedAt is null ? null : ticket.ResolvedAt - ticket.CreatedAt;

        public string Summary =>
            $"[{ticket.Status}] {ticket.Subject} (#{ticket.Id.ToString()[..8]})";

        public string PriorityLabel =>
            ticket.IsOverdue ? "High" : ticket.Status == TicketStatus.Pending ? "Medium" : "Normal";
    }

    // Static extensions: ways to create tickets
    extension(Ticket)
    {
        public static Ticket CreateDraft(string subject, string body) =>
            new(Guid.NewGuid(), subject, body, TicketStatus.Open, DateTimeOffset.UtcNow, null);
    }
}

Usage reads like the members were declared on Ticket from the start:

var ticket = Ticket.CreateDraft(
    "Cannot export report",
    "Clicking Export produces an empty file.");

Console.WriteLine(ticket.Summary);        // [Open] Cannot export report (#a1b2c3d4)
Console.WriteLine(ticket.PriorityLabel);  // Normal

var resolved = ticket with
{
    Status = TicketStatus.Resolved,
    ResolvedAt = DateTimeOffset.UtcNow.AddHours(3)
};

Console.WriteLine(resolved.ResolutionTime); // 03:00:00
Console.WriteLine(resolved.IsOverdue);      // False

And because the extensions also work on collections, you can compose them with LINQ:

public static class TicketCollectionExtensions
{
    extension(IEnumerable<Ticket> tickets)
    {
        public IEnumerable<Ticket> Overdue => tickets.Where(t => t.IsOverdue);

        public static IEnumerable<Ticket> Combine(
            IEnumerable<Ticket> first,
            IEnumerable<Ticket> second) => first.Concat(second);

        public static IEnumerable<Ticket> operator +(
            IEnumerable<Ticket> first,
            IEnumerable<Ticket> second) => first.Concat(second);
    }
}
IEnumerable<Ticket> all = backlog + archive;
Console.WriteLine($"Overdue: {all.Overdue.Count()}");

How the compiler sees it

It’s worth knowing what’s actually generated, because it explains most of the rules:

  • Both syntaxes produce the same IL. The this-modifier form and the extension block form are source and binary compatible. Consumers can’t tell which one you used, and you can convert existing extension methods to blocks without a breaking change.
  • The class must be top-level, non-generic, and static. Extension blocks can’t live in nested classes, and they can’t be declared in a generic static class. Put the type parameters on the block instead.
  • Extensions are static under the hood. For static extension members, the receiver type is folded into the generated member name. For instance extension members, the receiver becomes the first parameter.
  • XML docs on the block are copied to every member. Document the receiver once with <param name="ticket">, and each generated member inherits that node, so IntelliSense shows the receiver meaningfully.
  • An extension doesn’t create a scope. All members declared in the same class must have unique signatures, even across different blocks. Two blocks can’t both declare Summary for the same receiver.

Rules and limitations

  • Instance members always win. If the type has a member with the same name and signature, the compiler binds to it. Extension members can’t override, hide, or shadow real members.
  • No private access. Extension members are not part of the type, so private and protected members stay invisible.
  • Only methods, properties, and operators. You can’t declare fields, events, constructors, or finalizers in an extension block. Indexers arrive in C# 15.
  • Not visible to reflection or serialization. typeof(Customer).GetProperties() won’t show FullName, and System.Text.Json won’t serialize it. Extension members exist at compile time only.
  • Not mockable and not injectable. They’re static methods, so they can’t be substituted by a mocking framework and they don’t participate in dependency injection.
  • ref receivers need their own block. By-value and by-ref receivers for the same type are different blocks.
  • Namespace import required. If the namespace containing the extensions isn’t imported, the members simply aren’t in scope—and IntelliSense won’t offer them.

Should you migrate existing extension methods?

Almost never for the sake of it. Since both forms compile to identical IL, existing extension methods keep working, and rewriting them is churn with no behavioural benefit.

Reach for the new syntax when:

  • You need an extension property rather than a method—computed values, predicates, formatting.
  • You want a static member to appear on the type, such as a factory or a default value.
  • You need an operator on a type you don’t own.
  • You need a ref receiver to mutate a struct.
  • You have several related members sharing one receiver and want them grouped in a single readable block instead of scattered across a static class.

Keep the classic this-modifier form for one-off simple methods, or when your library still targets an older language version. It’s not deprecated, and there’s no plan to remove it.

Common mistakes to avoid

  • Putting extension blocks in a generic static class. That’s a compile error—the type parameters belong on the extension declaration.
  • Declaring the same signature twice. Because blocks share the class scope, duplicated member names or signatures collide across blocks.
  • Assuming an extension property is part of the type’s contract. It won’t show up in serialization, mapping, or reflection-based tooling.
  • Expecting extension members to win over instance members. They never do.
  • Trying to access private state. Extension members are outsiders; the compiler enforces it.
  • Scattering related members across many classes. Extensions are resolved by namespace, so keeping one receiver’s members together in one static class makes them far easier to find and to remove later.
  • Using extensions for behaviour you own. If you control the source of the type, add a real member. Extension members are for when you don’t.

Conclusion

Extension members are one of the more substantial additions in C# 14. The extension block syntax doesn’t just tidy up extension methods—it expands what you can express: extension properties that read like real members, static extensions that put factories and defaults on the type, operators for types you don’t own, and ref receivers for mutable structs.

Because the new syntax compiles to the same IL as the classic this-modifier form, adopting it is low risk. You can introduce blocks where they add clarity today and leave existing extension methods untouched.

If you’ve ever written customer.GetFullName() and wished you could write customer.FullName, or written a helper class nobody could discover, C# 14 gives you a cleaner way to express intent—without inheritance, without recompiling, and without touching a type you don’t own.