Unlock C# 14 Extension Members

C# 14 introduces a fresh approach to extension methods with extension members. These allow us to group related extensions in a more natural and organized way. One small but important detail: extension declarations must live inside non-generic, non-nested static classes. Additionally, extension is now a reserved keyword, so you can no longer name a type extension.

How Do We Declare Extension Members?

We declare them using the new extension keyword. Here’s a gentle introduction with a simple example:

public static class Ext
{
    extension(int number) // 'number' is the receiver parameter
    {
        public int Inc() => number + 1;           // Instance method – feels like it's on int
        public static int Default() => 32;        // Static method – no access to the receiver
    }

    extension(DateTime dt)
    {
        // Additional date-related extensions can go here
    }
}

You can place as many extension blocks as you need inside the same static class. It’s like creating dedicated buckets for extensions on different types.

For comparison, here’s how we’d write the same thing using the traditional approach we’re all familiar with:

public static class Ext
{
    public static int Inc(this int number) => number + 1;
    public static int Default() => 32;
}

The classic style is undeniably concise for simple cases. But as our extensions grow—especially when we want properties, operators, or indexers—the new syntax starts to shine.

Quick note: The term “receiver parameter” might feel new (perhaps inspired by other languages), but it simply refers to the instance we’re extending—the implicit this we’ve always had.

Scoping Rules: What’s Available Inside an Extension Block?

Think of an extension block as a cozy little room where the receiver and any generic type parameters are always in reach. The language designers added a few clear rules to keep things predictable.

1. Receiver and Type Parameters Are Automatically in Scope

Inside the block, you can use the receiver (like number) and any type parameters directly—no need to repeat them on every member.

public static class Ext
{
    extension<T>(T[] ts)
    {
        public bool Contains(T item) => ts.Contains(item); // T and ts are readily available
    }
}

It’s similar to having an implicit this for the array, plus shared generics across all members. This reduces boilerplate when building a suite of related utilities.

2. Static Members Can’t Touch the Receiver

Static members inside the extension don’t have an instance to work with, so referencing the receiver is forbidden (except in nameof expressions).

extension<T>(T[] ts)
{
    public static bool StaticContains(T item) => ts.Contains(item); // Compile-time error
}

If you need static helpers, declare them outside the extension block or avoid referencing the receiver.

3. No Name Shadowing Allowed

You cannot reuse the name of the receiver or a type parameter for local variables, parameters, or even new type parameters inside members.

extension<T>(T[] ts)
{
    public void BadExample(int T, string ts) { } // Error – shadows type param and receiver
}

Pick distinct names for your locals and parameters—it’s a small habit that prevents subtle bugs.

4. Member Names Can Overlap, But Lookup Favors the Declaration

You can name a method the same as a type parameter or receiver, but simple name lookup will prioritize the declaration’s names.

extension<T>(T[] ts)
{
    public void T() { }               // Method named T
    public void Demo() => T();         // Refers to the type parameter T, not the method → error
}

To call the method when it’s shadowed, you’d need to qualify it (e.g., Ext.T<T>(ts)), but it’s usually clearer to avoid overlapping names.

Coexisting with Traditional Extension Methods

You can happily mix the new extension blocks with classic this extension methods in the same static class—just not inside an extension block itself.

public static class Ext
{
    extension(int number)
    {
        public int Inc() => number + 1;
    }

    // Perfectly fine outside the block
    public static int Multiply(this int num, int factor) => num * factor;
}

Inferrability for Non-Method Members

When adding properties, indexers, operators, or events, every generic type parameter declared on the extension must be inferable from the receiver type or the member’s parameters.

public static class ListExtensions
{
    extension<T>(List<T> list)
    {
        public int Count => list.Count;                  // OK – T appears in List<T>
        public T this[int index] => list[index];         // OK – indexer uses T via receiver

        // If we added an unused <U>, non-method members referencing U would fail inferrability
    }
}

Methods are exempt from this rule because the compiler can infer types from invocation arguments.

Uniqueness Across Blocks

All extension blocks targeting the same receiver type share a single declaration space within the enclosing static class. This means no duplicate member names or signatures—exactly like members in a regular type.

public static class StringArrayExtensions
{
    extension(string[] array)
    {
        public bool ContainsIgnoreCase(string value) => /* ... */;
    }

    extension(string[] arr) // Same receiver type!
    {
        public bool ContainsIgnoreCase(string value) => /* ... */; // Error – duplicate
        public string Join(string sep = ", ") => /* ... */;       // OK – unique
    }
}

Different receiver types get their own spaces, so name clashes aren’t an issue there.

Why Bother with Extension Members?

  • Readability: Callers write value.Inc() instead of Ext.Inc(value)—it feels more natural.
  • Grouping: Gather all extensions for a type under one logical umbrella.
  • Beyond methods: Seamlessly add properties, indexers, operators, and events without awkward workarounds.

In short, extension members let us write richer, more cohesive extensions while keeping the code clean and intuitive.

References


Leave a Reply

Your email address will not be published. Required fields are marked *