Advanced C# A 11 11 versions

C# 7 to C# 14

Every feature from C# 7.0 to C# 14, newest first. C# 14 gets a full section per feature. Older versions get one bullet per feature, a one-line purpose, a tiny sample, and a link to the page that goes deeper.

Interviewers like “what is new in C#” questions. They test whether you keep up and whether you know why a feature exists. Each sample below is compiled and tested on .NET 10 unless it is marked as a fragment. Fragments are features that need a whole project to show, such as top-level statements.

Contents

  1. Which runtime ships which version
  2. C# 14 (.NET 10)
  3. C# 13 (.NET 9)
  4. C# 12 (.NET 8)
  5. C# 11 (.NET 7)
  6. C# 10 (.NET 6)
  7. C# 9 (.NET 5)
  8. C# 8 (.NET Core 3.0)
  9. C# 7.0 to 7.3
  10. Recap

Which runtime ships which version

Each C# version is the default for one target framework. You can raise it with <LangVersion> in the project file. Features that need runtime support, such as default interface methods or the Lock type, still need the newer runtime.

7.07.1 7.27.3 89 1011 1213 14 20172017 20172018 20192020 20212022 20232024 2025 Core 3.0.NET 6 .NET 8.NET 10 .NET 5.NET 7 .NET 9 Visual Studio 2017 updates blue: one version per .NET release, every November
Figure 11.1 — From C# 8 on, each language version ships with one .NET release.

Reading the figure. Grey dots are the 7.x point releases. Blue dots pair one C# version with one .NET release. The green dot is C# 14, the current version with .NET 10. Even .NET numbers (6, 8, 10) are long-term support releases.

C# 14 (.NET 10)

C# 14 has eight features. The biggest is extension members. The rest remove small daily friction.

1. Extension members

What

An extension block inside a static class names a receiver once. Inside it you can declare extension properties, methods, and static members that look like they belong to the extended type. Old this-parameter extension methods still work, and the two styles mix freely.

Why

Before C# 14 you could only add instance methods. You could not add a property such as text.WordCount or a static factory such as List<int>.Repeat(...). Grouping members by receiver also removes repeated this parameters.

public static class V14TextExtensions
{
    // Instance extension block: s is the receiver, named once for every member.
    extension(string s)
    {
        /// <summary>Number of space-separated words.</summary>
        /// <example><c>"a b  c".WordCount</c> returns 3.</example>
        public int WordCount => s.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;

        /// <summary>Upper-cases the text and adds "!".</summary>
        /// <returns>The shouted text.</returns>
        /// <example><c>"hi".Shout()</c> returns "HI!".</example>
        public string Shout() => s.ToUpperInvariant() + "!";
    }

    // Static extension block: no receiver name, members are called on the type.
    extension(string)
    {
        /// <summary>A line of dashes.</summary>
        /// <param name="n">How many dashes.</param>
        /// <returns>The dashes.</returns>
        /// <example><c>string.Dashes(3)</c> returns "---".</example>
        public static string Dashes(int n) => new('-', n);
    }

    // Generic receiver: works for every IEnumerable<T>.
    extension<T>(IEnumerable<T> source)
    {
        /// <summary>true when the sequence has no items.</summary>
        /// <example><c>new int[0].IsEmpty</c> returns true.</example>
        public bool IsEmpty => !source.Any();
    }

    // Static members on a generic type.
    extension<T>(List<T>)
    {
        /// <summary>A list holding count copies of item.</summary>
        /// <param name="item">The value to repeat.</param>
        /// <param name="count">How many copies.</param>
        /// <returns>The new list.</returns>
        /// <example><c>List<int>.Repeat(7, 2)</c> returns [7, 7].</example>
        public static List<T> Repeat(T item, int count) => [.. Enumerable.Repeat(item, count)];
    }
}

public static class V14ExtensionDemo
{
    /// <summary>Uses every kind of extension member once.</summary>
    /// <returns>The results joined by spaces.</returns>
    /// <example><c>Demo()</c> returns "3 HI! --- True 2".</example>
    public static string Demo()
    {
        int words = "a b  c".WordCount;            // instance extension property
        string loud = "hi".Shout();                // instance extension method
        string line = string.Dashes(3);            // static extension method, 3 dashes
        bool empty = Array.Empty<int>().IsEmpty;   // generic extension property
        int copies = List<int>.Repeat(7, 2).Count; // static member on List<T>, 2 copies
        return $"{words} {loud} {line} {empty} {copies}";
    }
}
What you write What the compiler calls, roughly text.WordCount V14TextExtensions.get_WordCount(text) string.Dashes(3) V14TextExtensions.Dashes(3) The receiver becomes the first argument. No type is changed at run time.
Figure 11.2 — Extension members are still static methods, so the extended type never changes.

Reading the figure. Blue boxes are the call sites you write. Green boxes are what runs. An instance member passes the receiver as the first argument. A static member passes nothing extra.

Pitfalls

Interview questions

Q. Can an extension property add data to an object?
A. No. It is a static method with property syntax. To attach data to an object you do not own, use ConditionalWeakTable.

2. The field keyword

What

Inside a property accessor, field refers to the compiler-made backing field. You can write logic in one accessor and keep the other one automatic.

Why

Before, adding one line of validation to an auto-property forced you to declare a private field by hand. Now the property stays a single declaration.

public sealed class V14Person
{
    // set trims, get stays automatic. field is the hidden backing store.
    public string Name { get; set => field = value.Trim(); } = "";

    // Validation without a hand-written _age field.
    public int Age
    {
        get;
        set => field = value >= 0 ? value : throw new ArgumentOutOfRangeException(nameof(value));
    }

    // Lazy default: computed on first read, then stored.
    public string Display => field ??= $"{Name} ({Age})";

    /// <summary>Sets a padded name and an age, then reads them back.</summary>
    /// <returns>The display text.</returns>
    /// <example><c>V14Person.Demo()</c> returns "Ann (30)".</example>
    public static string Demo()
    {
        var p = new V14Person { Name = "  Ann ", Age = 30 };   // 30: sample age
        return p.Display;
    }
}

Pitfalls

Interview questions

Q. Does field change the compiled output compared to a hand-written private field?
A. No. The compiler emits a private backing field just like for an auto-property. Only the source gets shorter.

3. Null-conditional assignment

What

?. and ?[] can now appear on the left of = and of compound assignments such as +=. If the receiver is null, nothing happens, and the right side is not evaluated.

Why

It replaces the if (x is not null) x.Prop = value; pattern with one line.

public sealed class V14Order
{
    public string Status = "new";
    public int Items;
    public int[] Slots = [0, 0];
}

public static class V14NullAssign
{
    /// <summary>Assigns through ?. on a null and a non-null order.</summary>
    /// <returns>Status, items, slot 1, and how often the right side ran.</returns>
    /// <example><c>Demo()</c> returns "paid 3 9 calls=3".</example>
    public static string Demo()
    {
        int calls = 0;
        string Paid() { calls++; return "paid"; }

        V14Order? missing = null;
        missing?.Status = Paid();                  // receiver null: Paid() never runs

        V14Order? order = new();
        order?.Status = Paid();                    // runs: calls becomes 1
        order?.Items += 3;                         // compound works. 3: sample count
        order?.Slots[1] = 9;                       // index 1: second slot. 9: sample
        Paid(); Paid();                            // two direct calls, so calls = 3
        return $"{order!.Status} {order.Items} {order.Slots[1]} calls={calls}";
    }
}

Pitfalls

Interview questions

Q. In a?.B = F(), when does F run?
A. Only when a is not null. The whole assignment short-circuits.

4. nameof with unbound generic types

What

nameof(List<>) and nameof(Dictionary<,>) now compile. Before, you had to pick a dummy type argument such as List<int>.

Why

Logging, error messages and analyzers name generic types without caring about the arguments.

public static class V14NameOf
{
    /// <summary>Names generic types and a member without type arguments.</summary>
    /// <returns>The names joined by spaces.</returns>
    /// <example><c>Demo()</c> returns "List Dictionary Count".</example>
    public static string Demo() =>
        $"{nameof(List<>)} {nameof(Dictionary<,>)} {nameof(List<>.Count)}";
}

Pitfalls

Interview questions

Q. Is nameof evaluated at run time?
A. No. It becomes a string constant at compile time. So it costs nothing and survives renames.

5. Implicit span conversions

What

Arrays, Span<T>, ReadOnlySpan<T> and string now convert to spans as built-in language conversions. Three new things follow. Extension methods on spans work on arrays. Generic type inference sees through the conversion. And arrays convert covariantly, so string[] becomes ReadOnlySpan<object>.

Why

The BCL adds span overloads everywhere for speed. Before C# 14, callers often had to write .AsSpan() to reach them. Now the fast overload is picked by default.

T[] Span<T> ReadOnlySpan<T> string as ReadOnlySpan<char> All arrows are now language conversions, so extension lookup and inference use them.
Figure 11.3 — Every arrow ends at a span type, and C# 14 treats each arrow as a built-in conversion.

Reading the figure. Blue boxes are what you hold. Amber is the writable span. Green is the read-only span, which every arrow can reach. Because these are language conversions now, the compiler uses them when it looks for extension methods and infers T.

public static class V14SpanExtensions
{
    /// <summary>Counts even numbers in a read-only span.</summary>
    /// <param name="items">The numbers.</param>
    /// <returns>How many are even.</returns>
    /// <example><c>new[] { 1, 2, 4 }.CountEven()</c> returns 2.</example>
    public static int CountEven(this ReadOnlySpan<int> items)
    {
        int count = 0;
        foreach (int x in items)
        {
            if (x % 2 == 0) count++;               // % 2 == 0: even
        }
        return count;
    }

    /// <summary>Generic span method. C# 14 infers T from an array argument.</summary>
    /// <param name="items">The items.</param>
    /// <returns>The first item.</returns>
    /// <example><c>FirstOf(new[] { "x" })</c> returns "x".</example>
    public static T FirstOf<T>(ReadOnlySpan<T> items) => items[0];   // 0: first slot

    /// <summary>Counts items in a span of objects.</summary>
    /// <param name="items">Any objects.</param>
    /// <returns>The length.</returns>
    /// <example>A string[] of 2 converts covariantly and returns 2.</example>
    public static int CountObjects(ReadOnlySpan<object> items) => items.Length;

    /// <summary>Uses each new conversion once.</summary>
    /// <returns>The three results joined by spaces.</returns>
    /// <example><c>Demo()</c> returns "2 x 2".</example>
    public static string Demo()
    {
        int[] numbers = [1, 2, 4];
        string[] words = ["x", "y"];
        int evens = numbers.CountEven();           // extension on span, called on an array
        string first = FirstOf(words);             // T inferred as string
        int count = CountObjects(words);           // string[] to ReadOnlySpan<object>
        return $"{evens} {first} {count}";
    }
}

Pitfalls

Interview questions

Q. Why did numbers.CountEven() fail before C# 14?
A. The array-to-span conversion was a user-defined operator. Extension method lookup ignores user-defined conversions on the receiver. Making it a language conversion fixed that.

6. Simple lambda parameters with modifiers

What

Lambda parameters can carry ref, out, in, scoped or ref readonly without spelling out their types. The types come from the target delegate.

Why

Before, one modifier forced you to type every parameter, as in (string text, out int result) => ....

public delegate bool V14TryParse<T>(string text, out T result);
public delegate void V14Bump(ref int value);

public static class V14LambdaModifiers
{
    /// <summary>Uses out and ref on untyped lambda parameters.</summary>
    /// <returns>The parsed number and the bumped value.</returns>
    /// <example><c>Demo()</c> returns "42 True 6".</example>
    public static string Demo()
    {
        // text and result get their types from V14TryParse<int>.
        V14TryParse<int> parse = (text, out result) => int.TryParse(text, out result);
        bool ok = parse("42", out int n);

        V14Bump twice = (ref value) => value *= 2;   // * 2: doubles in place
        int x = 3;                                    // 3: sample input
        twice(ref x);
        return $"{n} {ok} {x}";
    }
}

Pitfalls

Interview questions

Q. Can you mix typed and untyped lambda parameters?
A. No. Either every parameter has a type or none does. Modifiers may appear in both forms.

7. Partial constructors and partial events

What

Instance constructors and events can now be partial. One part declares the signature. The other part supplies the body, or the add and remove accessors.

Why

Source generators need it. You declare what you want, and a generator writes the code. C# 9 did this for methods, and C# 13 for properties and indexers. Constructors and events were the last members missing. See A 09 for generators.

// The "declaring" part, the side a human writes.
public partial class V14Widget
{
    public partial V14Widget(string name);
    public partial event Action<string>? Renamed;
}

// The "implementing" part, normally produced by a source generator.
public partial class V14Widget
{
    private Action<string>? _renamed;
    public string Name { get; private set; }

    public partial V14Widget(string name) { Name = name; }

    public partial event Action<string>? Renamed
    {
        add => _renamed += value;
        remove => _renamed -= value;
    }

    /// <summary>Changes the name and raises Renamed.</summary>
    /// <param name="name">The new name.</param>
    /// <example><c>w.Rename("b")</c> raises Renamed with "b".</example>
    public void Rename(string name)
    {
        Name = name;
        _renamed?.Invoke(name);
    }

    /// <summary>Builds a widget, subscribes, renames.</summary>
    /// <returns>The name the handler saw.</returns>
    /// <example><c>V14Widget.Demo()</c> returns "beta".</example>
    public static string Demo()
    {
        var w = new V14Widget("alpha");
        string seen = "";
        w.Renamed += n => seen = n;
        w.Rename("beta");
        return seen;
    }
}

Pitfalls

Interview questions

Q. Why do partial members matter for performance?
A. They let a source generator write code at build time instead of using reflection at run time. That helps startup and Native AOT.

8. User-defined compound assignment and ++/--

What

A type can now define operator +=, -= and friends, and prefix ++ and --, as instance methods that return void. They change the object in place.

Why

Before, a += b always meant a = a + b. That builds a new object. For big mutable values such as a matrix or a buffer, the copy is wasted work.

public sealed class V14Accumulator
{
    public long Total { get; private set; }
    public int Count { get; private set; }

    // Instance compound operator: changes this object, no new instance.
    public void operator +=(long value)
    {
        Total += value;
        Count++;
    }

    // Instance increment: in-place +1.
    public void operator ++()
    {
        Total++;
        Count++;
    }

    /// <summary>Adds two values and one increment.</summary>
    /// <returns>Total, count, and whether the object stayed the same.</returns>
    /// <example><c>Demo()</c> returns "16 3 True".</example>
    public static string Demo()
    {
        var acc = new V14Accumulator();
        var same = acc;
        acc += 10;                                 // 10 and 5: sample amounts
        acc += 5;
        acc++;                                     // +1, so the total is 16
        return $"{acc.Total} {acc.Count} {ReferenceEquals(acc, same)}";
    }
}

Pitfalls

Interview questions

Q. Why make the new operators instance methods?
A. They change this. A static operator cannot change its operand in place without a ref, and it must return a new value.

C# 13 (.NET 9)

C# 12 (.NET 8)

C# 11 (.NET 7)

C# 10 (.NET 6)

C# 9 (.NET 5)

C# 8 (.NET Core 3.0)

C# 7.0 to 7.3

C# 7.3

C# 7.2

C# 7.1

C# 7.0

Say this out loud: “C# 14 added extension members, so I can write extension properties and static extensions. It also added the field keyword and null-conditional assignment. Implicit span conversions make the fast span overloads the default.”

Recap

The things to carry forward

Where this goes next

That is the last page. Go back to the C# interview home to pick a pattern or a guide to review.


← A 10 — Concurrency C# interview home →