Portfolio

extensions

C# 14 extension members: APIs that feel like they belonged there

A practical look at C# 14 extension blocks, extension properties, static extension members, operators, and why they are useful for low-allocation fluent APIs.

September 22, 2025 9 min read Marvin Drude
C#.NETExtensionsC# 14API DesignPerformance

Why extension members matter

Classic extension methods solved a very specific problem: you could make a type feel richer without owning that type.

That was already powerful:

C#
public static bool IsEmpty<T>(this IEnumerable<T> source)
{
    return !source.Any();
}

But the shape was always limited. You could add methods, and only instance-looking methods. If the API wanted a property, a static factory, or an operator, you either had to make the call less natural or move the behavior somewhere else.

C# 14 extension members widen that design space. Extension blocks let you group related members for a receiver type and declare methods, properties, static members, and operators in one place.

The result is not just nicer syntax. It gives library code a better way to express intent.

The new shape

The new syntax is built around an extension block inside a top-level static class:

C#
public static class EnumerableExtensions
{
    extension<T>(IEnumerable<T> source)
    {
        public bool IsEmpty => !source.Any();

        public IEnumerable<T> WhereNotNull()
        {
            foreach (var item in source)
            {
                if (item is not null)
                {
                    yield return item;
                }
            }
        }
    }
}

The receiver is declared once: IEnumerable<T> source. Everything inside the block extends that receiver.

That small change matters when a type has a set of related helpers. Instead of repeating this SomeType value for every method, the extension target becomes the local context of the block.

What C# 14 adds

Extension blocks are most useful when you stop thinking of them as "extension methods with different braces" and start thinking of them as an API surface for types you do not own.

The practical additions are:

  • instance extension properties
  • instance extension methods grouped by receiver
  • static extension members on the type
  • extension operators
  • receiver modifiers such as ref, in, and scoped where they make sense
  • cleaner generic constraints at the receiver level

That means these two ideas can live next to each other:

Instance-level helpers

C#
public static class CollectionExtensions
{
    extension<T>(IReadOnlyCollection<T> collection)
    {
        public bool IsEmpty => collection.Count == 0;
        public bool HasItems => collection.Count > 0;
    }
}

And:

Type-level helpers

C#
public static class IdExtensions
{
    extension(EntityId)
    {
        public static EntityId New() => new(Guid.CreateVersion7());
    }
}

The first block extends instances. The second block extends the type itself.

Instance properties are the everyday win

The most obvious improvement is extension properties.

Before C# 14, a property-like concept had to be a method:

C#
if (orders.IsEmpty())
{
    return;
}

That is fine, but it reads like work. A property is a better fit when the value is cheap, deterministic, and does not imply a command:

C#
if (orders.IsEmpty)
{
    return;
}

That distinction is small but important for API design. Methods usually suggest an operation. Properties suggest a view over state.

Use extension properties for values that are:

  • cheap to compute
  • side-effect free
  • not surprising when read multiple times
  • semantically a characteristic of the receiver

Do not hide expensive database calls, file reads, allocations, or network work behind extension properties. The new syntax makes the API smoother, but it does not remove the normal responsibility to be honest about cost.

Static extension members

Static extension members let you attach factory-like or type-level behavior to a type you do not own.

Imagine an ID type that is deliberately small:

C#
public readonly record struct CustomerId(Guid Value);

You may not want the type itself to know about every parsing, generation, or framework-specific helper. With static extension members, that can live outside the type while still being called from the type:

C#
public static class CustomerIdExtensions
{
    extension(CustomerId)
    {
        public static CustomerId New()
        {
            return new CustomerId(Guid.CreateVersion7());
        }

        public static bool TryParse(ReadOnlySpan<char> value, out CustomerId id)
        {
            if (Guid.TryParse(value, out var guid))
            {
                id = new CustomerId(guid);
                return true;
            }

            id = default;
            return false;
        }
    }
}

That enables code shaped like this:

C#
var customerId = CustomerId.New();

if (!CustomerId.TryParse(input, out var parsed))
{
    return Results.BadRequest();
}

This is useful when you want a clean domain type but still need adapters, parsers, test factories, or integration helpers in separate namespaces.

Extension operators

Operators are another place where old extension methods could not reach.

With extension operators, you can make small mathematical or domain types compose more naturally without putting every operation on the original type.

C#
public readonly record struct Bytes(long Value);

public static class BytesExtensions
{
    extension(Bytes)
    {
        public static Bytes operator +(Bytes left, Bytes right)
        {
            return new Bytes(left.Value + right.Value);
        }
    }
}

That makes the consuming code say what it means:

C#
var payload = new Bytes(18_432);
var headers = new Bytes(720);

var total = payload + headers;

Operators should still be rare. They are best when the operation is obvious, symmetric, and unsurprising. If the operator needs a paragraph of explanation, a named method is usually better.

Span-friendly extension blocks

The old article used spans as the first example, and that is still a good place to look because receiver modifiers matter there.

You can extend a span-like receiver without copying the receiver around:

C#
public static class SpanExtensions
{
    extension<T>(scoped in Span<T> span)
    {
        public ref T First => ref span[0];
        public ref T Last => ref span[^1];
    }
}

That creates a tiny API for a hot path:

C#
Span<int> numbers = stackalloc[] { 10, 20, 30 };

numbers.First = 1;
numbers.Last = 99;

This kind of code is not something you should spray across normal business logic. It is useful when you are already in performance-sensitive code and the receiver lifetime is clear.

A low-allocation serialization helper

Here is a more realistic example: writing unmanaged values into caller-owned memory.

C#
using System.Buffers.Binary;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

public static class UnmanagedExtensions
{
    extension<T>(T value)
        where T : unmanaged
    {
        public int WriteBigEndian(scoped Span<byte> destination)
        {
            var size = Unsafe.SizeOf<T>();

            if (destination.Length < size)
            {
                throw new ArgumentException("Destination is too small.", nameof(destination));
            }

            MemoryMarshal.Write(destination[..size], in value);

            if (BitConverter.IsLittleEndian)
            {
                destination[..size].Reverse();
            }

            return size;
        }
    }
}

Usage stays compact:

C#
Span<byte> buffer = stackalloc byte[sizeof(int)];

var written = 42.WriteBigEndian(buffer);

The design here is intentional:

  • caller owns the memory
  • the method writes into a Span<byte>
  • the only result is the number of bytes written
  • no intermediate array is needed

This is where extension members fit well: a small, focused capability attached to a broad receiver without pretending the receiver type owns that behavior forever.

The ref struct builder problem

The most interesting use case from my old article was a source-generation builder.

Source generators often create a lot of text. You want the API to be pleasant, but you do not want every interpolated line, nested builder, or helper call to allocate more than necessary.

That leads naturally to ref struct builders:

  • they can stay stack-only
  • they can wrap span-backed writers
  • they make ownership and lifetime explicit
  • they keep hot generation paths honest about memory

The tradeoff is API composition. A normal class hierarchy can share methods through a base class. A ref struct builder cannot lean on that same model in the same way.

Imagine these builders:

C#
public ref struct FileBuilder
{
    private CodeBuilder _builder;

    public ref CodeBuilder Builder => ref _builder;
}

public ref struct TypeHeaderBuilder
{
    private CodeBuilder _builder;

    public ref CodeBuilder Builder => ref _builder;
}

public ref struct MethodBuilder
{
    private CodeBuilder _builder;

    public ref CodeBuilder Builder => ref _builder;
}

They all need common methods:

  • Write
  • WriteLine
  • WriteLineInterpolated
  • UpIndent
  • DownIndent
  • OpenBody
  • CloseBody

Duplicating those methods in every builder is boring and risky. A base class is not a good fit. Extension blocks give you another route.

A shared builder contract

Start with a small contract that exposes the underlying writer state:

C#
public interface ICodeBuilder
{
    ref CodeBuilder GetBuilder();
}

Each specialized builder implements that contract:

C#
public ref struct FileBuilder : ICodeBuilder
{
    private CodeBuilder _builder;

    public ref CodeBuilder GetBuilder()
    {
        return ref _builder;
    }
}

Now the shared fluent API can live once:

C#
using System.Runtime.CompilerServices;

public static class CodeBuilderExtensions
{
    extension<T>(ref T builder)
        where T : struct, ICodeBuilder, allows ref struct
    {
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public ref T WriteLine(string line)
        {
            ref var writer = ref builder.GetBuilder().Writer;
            writer.WriteLine(line);

            return ref builder;
        }

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public ref T WriteLine()
        {
            ref var writer = ref builder.GetBuilder().Writer;
            writer.WriteLine();

            return ref builder;
        }

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public ref T UpIndent()
        {
            builder.GetBuilder().Writer.UpIndent();
            return ref builder;
        }

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public ref T DownIndent()
        {
            builder.GetBuilder().Writer.DownIndent();
            return ref builder;
        }
    }
}

The important part is the receiver:

C#
extension<T>(ref T builder)
    where T : struct, ICodeBuilder, allows ref struct

That says the extension works on a builder by reference, preserves fluent chaining with ref T, and still allows stack-only builder implementations.

Fluent usage

The consuming code can now read like a normal builder API:

C#
builder.File
    .WriteStartAutoGenerated()
    .WriteLine("// generated by Beskar.CodeGeneration")
    .WriteLine("// keep edits in the source model")
    .WriteEndAutoGenerated()
    .WriteNullableEnable()
    .WriteUsing("System")
    .WriteUsing("System.Runtime.CompilerServices");

And a type header can use the same shared methods:

C#
builder.TypeHeader
    .WriteAccessInternal()
    .WriteClassModifiers(ClassModifier.Sealed | ClassModifier.Partial)
    .WriteClass("GeneratedLookup")
    .OpenBody()
    .WriteLine("private static readonly Dictionary<string, int> Map = [];")
    .CloseBody();

That is the real win: the syntax is clean for the caller, while the implementation can stay close to the memory model.

When to use extension blocks

Use extension blocks when the members form a coherent API around one receiver.

Good candidates:

  • domain value helpers
  • parsing and formatting helpers
  • span and memory utilities
  • source-generation builders
  • query-like helpers for collection types
  • adapters for framework types you do not own

Weak candidates:

  • random helper methods collected by convenience
  • APIs that hide expensive work behind property syntax
  • behavior that should belong to the original type
  • large domain services disguised as methods on primitive types
  • operators that are cute but unclear

Extension members make APIs feel more native. That is exactly why they need restraint. A bad extension property can be more misleading than a bad method because it looks so cheap.

Migration strategy

You do not need to rewrite every classic extension method.

Keep the old form when it is just one simple method:

C#
public static string Truncate(this string value, int maxLength)
{
    return value.Length <= maxLength ? value : value[..maxLength];
}

Move to extension blocks when you have a family of members:

C#
public static class StringExtensions
{
    extension(string value)
    {
        public bool IsBlank => string.IsNullOrWhiteSpace(value);

        public string Truncate(int maxLength)
        {
            return value.Length <= maxLength ? value : value[..maxLength];
        }
    }
}

That makes the grouping visible in the source code, not only in a file name.

Conclusion

C# 14 extension members are not just syntax candy. They let extension APIs express more of the shape that real APIs already have: properties for cheap facts, methods for operations, static members for type-level behavior, and operators for obvious composition.

The feature is especially useful when you care about API ergonomics and runtime cost at the same time. The ref struct builder example is the clearest case: extension blocks let shared fluent APIs stay readable without forcing a class hierarchy or unnecessary allocation.

Used well, extension members make external APIs feel native. Used poorly, they can make hidden behavior harder to see.

The rule I would use is simple: extend a type only when the added member feels like it genuinely belongs to that type from the caller's point of view.

An unhandled error has occurred. Reload 🗙

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please retry or reload the page.