C# has shipped a major version every year since 2020, and an engineer with ten or more years of experience has usually lived through several distinct eras of the language firsthand: ArrayList and casts, LINQ and lambdas, async/await, nullable reference types, and now records, primary constructors and C# 14 extension members. Interviewers at the senior, lead and architect level rarely ask you to recite syntax. They want to know what the compiler actually generates underneath a feature, where it interacts badly with older code or with libraries such as EF Core and JSON serializers, and whether you would introduce it into a codebase that ships every two weeks. The questions below span C# 8 through C# 14 and focus on the features that come up most in real interviews and real code reviews: default interface methods, ranges and indices, records, init and required members, primary constructors, collection expressions, raw string literals, extension members and the field keyword. Expect to defend trade-offs out loud, not just describe syntax.

Q1 What problem do default interface methods solve, and what are their real limitations in production code?#

Short answer: Default interface methods, added in C# 8, let an interface supply a method body so you can add a new member to a published interface without breaking every type that already implements it. They are not a backdoor for multiple inheritance of state: an interface still cannot declare instance fields, and the member is reachable only through an interface-typed reference unless the implementing type also declares it.

Before C# 8, adding a method to a public interface was a source-breaking change for every external implementer, so library authors either shipped a new interface or waited for a major version. A default implementation lets the interface evolve in place. The feature needed CLR support that first shipped in .NET Core 3.0, so it does not work when a netstandard2.0 library is consumed from .NET Framework, and interfaces can also declare private and static members to support the default implementation internally.

C#
public interface IRateLimiter
{
    bool TryAcquire();

    // Added after IRateLimiter shipped; existing implementers keep compiling.
    async Task WaitAsync(CancellationToken ct)
    {
        while (!TryAcquire())
            await Task.Delay(50, ct);
    }
}

public sealed class TokenBucketLimiter : IRateLimiter
{
    public bool TryAcquire() => true; // simplified
}

IRateLimiter limiter = new TokenBucketLimiter();
await limiter.WaitAsync(CancellationToken.None);   // resolves through the interface

var concrete = new TokenBucketLimiter();
// concrete.WaitAsync(...);                        // compile error: not a member of TokenBucketLimiter

What interviewers look for: that you treat this as a versioning tool for published interfaces, not a routine application design choice; that you know it requires modern runtime support; and that you can explain why concrete.WaitAsync(...) fails to compile even though TokenBucketLimiter implements IRateLimiter.

  • Common mistakes: assuming the default method becomes part of the concrete type's public surface; assuming this gives interfaces mutable state.
  • Follow-up questions: How would you resolve a diamond, where a class implements two interfaces with a same-signature default method? (The class must provide its own implementation to disambiguate.)

Q2 How do the Index and Range types change slicing, and how does the compiler translate a[1..^1]?#

Short answer: Index (the ^ operator) and Range (..), added in C# 8, give you a first-class way to address positions from the end and to slice a sequence without manual arithmetic. The compiler does not hard-code array behavior: it looks for an indexer or Slice method on the target type that accepts Index or Range, so the same syntax works on arrays, strings, spans and your own types.

^n means Length - n, so ^1 is the last element and ^0 is one past the end, which throws if you index with it directly. A Range combines a start and end Index and supports open forms such as a.. and ..b. The allocation story differs by target type: slicing an array with a range copies into a new array, while slicing a Span<T> or ReadOnlySpan<T> calls Slice and allocates nothing. Custom types opt in with a pattern-based Slice(int, int) method or an indexer that accepts Range, no interface required.

C#
int[] scores = [10, 20, 30, 40, 50];

int last = scores[^1];                // 50
int[] middle = scores[1..^1];         // new array [20, 30, 40]; allocates
ReadOnlySpan<int> span = scores.AsSpan()[1..^1]; // slices in place, no allocation

public readonly struct Ledger(IReadOnlyList<decimal> entries)
{
    public int Length => entries.Count;

    // Pattern-based slicing: no interface needed, just Length plus a Range indexer.
    public IReadOnlyList<decimal> this[Range range]
    {
        get
        {
            var (offset, length) = range.GetOffsetAndLength(Length);
            return entries.Skip(offset).Take(length).ToList();
        }
    }
}

What interviewers look for: that you distinguish allocating array slices from allocation-free span slices, and that you understand the pattern-based nature of the feature rather than assuming it needs a specific interface.

  • Common mistakes: assuming array range slicing is as cheap as span slicing; forgetting that ^0 is out of range, not "one past the end is fine to read."
  • Follow-up questions: How would you add range support to a type that only exposes IEnumerable<T>? (Materialize it once, or add your own indexer.)

Q3 Walk through everything the compiler generates for a one-line positional record, and where the generated equality surprises teams.#

Short answer: public record Money(decimal Amount, string Currency); expands into a primary constructor, init-only properties, a Deconstruct method, value-based Equals/GetHashCode/==/!=, an EqualityContract property that folds the runtime type into equality, and a ToString built from a virtual PrintMembers. The most common surprise is that equality is only as deep as each field's own equality, so a record holding an array or a List<T> compares those members by reference, not by content.

EqualityContract matters more than people expect: it means a base-typed instance never equals a derived instance holding the same values, which keeps polymorphic equality intuitive. The hash code combines the same fields as Equals, so a record used as a dictionary key must never change after insertion, which is one more reason to prefer readonly record struct or sealed record for keys. See records and pattern matching for the full generated-member list.

C#
public sealed record PriceQuote(decimal Amount, string Currency, string[] Notes);

var a = new PriceQuote(99.50m, "USD", ["rush"]);
var b = new PriceQuote(99.50m, "USD", ["rush"]);
Console.WriteLine(a == b);               // False: string[] compares by reference

public sealed record PriceQuoteFixed(decimal Amount, string Currency, IReadOnlyList<string> Notes)
{
    public bool Equals(PriceQuoteFixed? other) =>
        other is not null && Amount == other.Amount && Currency == other.Currency
        && Notes.SequenceEqual(other.Notes);

    public override int GetHashCode() => HashCode.Combine(Amount, Currency);
}

What interviewers look for: fluency with what is actually generated, not just "records have value equality"; awareness that collections break that promise by default.

  • Common mistakes: treating a with expression as calling the constructor (it clones and then runs init accessors, skipping constructor validation that is not repeated in an init accessor).
  • Follow-up questions: Why is a record generally a poor fit for an EF Core entity type? (EF Core tracks by reference identity, not value equality.)

Q4 What is the difference between init and required, and why do you usually want both together?#

Short answer: init (C# 9) makes a property settable only during construction: an object initializer, a constructor body, or a with expression. required (C# 11) is orthogonal: it forces every construction path to assign the member, so the compiler, not a code comment, guarantees a value is never left at its default. Combine them for immutable data that must never be partially built.

A required member must have a setter or init accessor at least as visible as the containing type, and you cannot mark a positional record parameter itself required; you redeclare that property explicitly if you need the modifier. If a positional record's properties are all satisfied by its primary constructor, the compiler attaches SetsRequiredMembersAttribute to that constructor automatically. If you hand-write a constructor that assigns every required member yourself, you can apply [SetsRequiredMembers] from System.Diagnostics.CodeAnalysis to let callers use that constructor directly instead of an object initializer, but the compiler trusts your assertion and does not re-check it.

C#
public sealed class CustomerProfile
{
    public required string DisplayName { get; init; }
    public required string Email { get; init; }
    public string? Phone { get; init; }

    [SetsRequiredMembers]
    public CustomerProfile(string displayName, string email)
    {
        DisplayName = displayName;
        Email = email;
    }
}

var a = new CustomerProfile { DisplayName = "Ada", Email = "[email protected]" }; // ok
var b = new CustomerProfile("Ada", "[email protected]");                          // ok: attribute trusted

What interviewers look for: precise knowledge of SetsRequiredMembersAttribute and its trust model, and the ability to explain why required exists even though constructors already enforce initialization in most codebases (it composes with object initializers and works across inheritance, where a derived type cannot silently drop a base type's requirement).

  • Common mistakes: believing required alone makes a property immutable; it only forces assignment, init supplies the immutability.
  • Follow-up questions: What happens if a derived class hides a required base member? (It is not allowed; a derived override of a required property must stay required.)

Q5 What changes when you move a class to a primary constructor in C# 12, and where is the capture behavior misunderstood?#

Short answer: A primary constructor's parameters are in scope throughout the type body, but they remain parameters, not members: you cannot write this.param, they can be reassigned, and unlike a positional record they never become public properties. The compiler only creates hidden field storage for a parameter that is actually referenced outside of a field or property initializer; a parameter used solely to compute a readonly property is not stored at all.

This "storage only if needed" rule has a sharp edge in class hierarchies: if a derived class's primary constructor both passes a parameter to base(...) and uses that same parameter later in its own body, the compiler creates a second, separate copy of the value in the derived class. That copy no longer reflects changes to the base class's property, and the compiler warns about it. The fix is to reference the base class's property, not the parameter, once construction is complete.

C#
public class BankAccount(string owner, string accountId)
{
    public string Owner { get; } = owner;
    public string AccountId { get; } = accountId;
}

// Anti-pattern: accountId is captured a second time here and drifts from AccountId.
public sealed class SavingsAccount(string owner, string accountId, decimal rate)
    : BankAccount(owner, accountId)
{
    public decimal Rate { get; } = rate;

    // Prefer AccountId (the base property) over the captured accountId parameter here.
    public override string ToString() => $"{AccountId}: {Owner} @ {Rate:P}";
}

What interviewers look for: the distinction between "in scope" and "stored as a field," and awareness of the duplicated-storage trap in inheritance, which is a real defect pattern, not a theoretical one.

  • Common mistakes: assuming primary constructor parameters are readonly by default; forgetting every other constructor on the type must chain to the primary constructor.
  • Follow-up questions: Why do positional records behave differently from a primary constructor on a plain class? (Records also generate public init properties and value equality; a plain class with a primary constructor generates neither.)

Q6 When do collection expressions save real code, and how does the compiler make [.. items] work for arbitrary types?#

Short answer: Collection expressions, [] and [a, b, .. other], unify how you build arrays, spans, List<T> and other collection types under one target-typed syntax, and the spread element .. flattens an IEnumerable<T> source inline. They pay off most where code previously juggled new[] { }, .ToList() and manual concatenation across several call sites that expect different collection types.

The compiler converts a collection expression to a span, an array, any type supporting a collection initializer (roughly, IEnumerable<T> with an accessible Add method), one of the standard read-only or mutable collection interfaces, or a custom type. A custom type opts in by exposing a static Create(ReadOnlySpan<T>)-shaped method and applying System.Runtime.CompilerServices.CollectionBuilderAttribute to point at it, which is exactly how the built-in immutable collection types support the syntax.

C#
int[] vowelCounts = [1, 2, 3];
List<string> tags = ["urgent", "eu", .. extraTags];       // spread flattens extraTags
ReadOnlySpan<char> letters = ['a', 'b', 'c'];

int[] Merge(int[] left, int[] right) => [.. left, .. right, 0]; // trailing sentinel

What interviewers look for: that you reach for collection expressions where they remove real duplication (shared helper methods returning different collection types, building one sequence from several sources), not as a blanket style rule; understanding of the CollectionBuilderAttribute mechanism for custom types.

  • Common mistakes: assuming collection expressions work with inline arrays (they do not; inline arrays need different syntax) or that spreading always allocates a new backing store for span targets (it does not, when the target type and size allow it).
  • Follow-up questions: How would you make your own fixed-capacity buffer type support [a, b, c] syntax? (Implement IEnumerable<T>, add a static Create(ReadOnlySpan<T>) factory, and apply CollectionBuilderAttribute.)

Q7 What are raw string literals for, and how do you combine one with interpolation without escaping every brace?#

Short answer: Raw string literals, """...""" from C# 11, hold arbitrary text, including quotes and backslashes, with no escape sequences at all, which makes embedded JSON, regular expressions and SQL far more readable. Combined with interpolation, you control how many literal braces the string can contain by using more than one leading $: with $$"""...""", a single { or } is emitted literally and an interpolation hole needs doubled braces, {{expr}}.

The delimiter is at least three double quotes, and it can be longer when the content itself contains runs of quotes; the string only needs to be delimited by more quotes than the longest run it contains. In a multi-line literal, the closing """ must be alone on its line and sets the common indentation: whitespace up to that column is stripped from every line, so you can indent the literal to match the surrounding code without polluting the value.

C#
string query = $$"""
    SELECT Id, Name
    FROM Customers
    WHERE Region = {{region}}   -- interpolation hole: double braces
    AND Tier = 'Gold'           -- a literal single brace would print as-is
    """;

string json = """
    { "type": "object", "required": ["id"] }
    """;

What interviewers look for: correct mechanics of the $$ and {{ }} rule, and a sense of where raw strings genuinely help (embedded DSLs, test fixtures, prompts) versus where a plain interpolated string is still clearer.

  • Common mistakes: mixing tabs and spaces before the closing delimiter, which is invalid; forgetting the closing quotes must start their own line in the multi-line form.
  • Follow-up questions: Why might a team standardize on raw string literals for embedded SQL or JSON test fixtures specifically? (No escaping means the literal matches what a database or API log actually shows, which reduces copy-paste errors.)

Q8 What do C# 14 extension members add over classic extension methods, and what can they still not do?#

Short answer: An extension(...) block in C# 14 lets you add instance and static properties and operators to a type you do not own, not just methods, while an instance extension method inside the block compiles to the exact same IL as a classic this-parameter method. The hard limit is state: extension blocks cannot declare fields or events, and their members never win a lookup against a real member the type later adds.

Grouping matters in review: one static class per extended concept, with a separate extension(...) block per receiver shape, keeps disambiguation simple when two static classes could both apply. Because everything lowers to static methods, you can always call the underlying method directly, Extensions.Method(receiver, args), to break a tie. See C# 14 features for the property and operator forms in full.

C#
public static class DateOnlyExtensions
{
    extension(DateOnly date)
    {
        public bool IsWeekend => date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
    }

    extension(DateOnly)
    {
        public static DateOnly TodayUtc => DateOnly.FromDateTime(DateTime.UtcNow);
    }
}

Console.WriteLine(DateOnly.TodayUtc.IsWeekend);

What interviewers look for: knowing that binary and source compatibility with classic extension methods is preserved, so migration is optional, and that the feature is not a way to attach state to a foreign type (that still needs ConditionalWeakTable or a wrapper type).

  • Common mistakes: expecting extension properties to override a real member added later; they silently lose, which is the correct and documented behavior.
  • Follow-up questions: How would you disambiguate two extension methods with the same signature from different static classes? (Call the generated static method explicitly, or bring only one namespace into scope.)

Q9 What does the field keyword solve in C# 14, and where can it silently change the meaning of existing code?#

Short answer: field is a contextual keyword usable inside a property accessor that refers to a compiler-synthesized backing field, so you can add small logic, such as trimming or validation, to one accessor without hand-declaring a field and writing both accessors yourself. It can break existing code that already used the identifier field inside a property accessor, because that identifier now binds to the synthesized field instead.

A property initializer still writes the backing field directly and does not invoke the setter, so you can set a default without triggering change notifications, while an assignment from a constructor calls the setter as normal. The compiler reports warning CS9258 for a pre-existing member whose meaning changed and an error for a local literally named field declared inside an accessor; escaping it as @field or qualifying it as this.field (if a real field also exists) resolves both.

C#
public sealed class CustomerViewModel
{
    public string Name
    {
        get;
        set => field = value?.Trim() ?? string.Empty;
    } = string.Empty;

    // Lazy initialization with no separate field declaration.
    public IReadOnlyList<string> Segments => field ??= LoadSegments();

    private static IReadOnlyList<string> LoadSegments() => ["retail", "wholesale"];
}

What interviewers look for: the initializer-versus-setter distinction, and awareness that this is a real, if narrow, breaking change worth a compiler-warning sweep before upgrading a large codebase to C# 14.

  • Common mistakes: assuming field is available in ordinary methods (it is only meaningful inside a property or indexer accessor); forgetting that a computed property with no accessor body cannot use field at all.
  • Follow-up questions: When would you still hand-declare a private field instead of using field? (When several members beyond the property itself need direct access to the storage.)

Q10 As a tech lead, how do you decide whether to mandate a new C# feature across a large codebase versus leave it opt-in?#

Short answer: Separate features that remove a class of bugs from features that are mainly stylistic. Mandate the former through analyzers and code review; let the latter spread organically as code is touched, and never force a language version ahead of the target framework's default just to unlock syntax.

Nullable reference types and required members are worth mandating for new code, because an analyzer enforces them and the payoff is fewer null-reference and missing-initialization defects in production. Primary constructors and extension members are closer to style: they read well but do not change what bugs are possible, so a rule such as "use them in new files, do not churn existing ones for the sake of it" avoids review noise and merge conflicts on files that were fine already. Tie the rollout to LangVersion following the target framework automatically, add the relevant analyzer rules to .editorconfig at the severity you intend to enforce, and budget one deliberate pass for anything you do decide to standardize retroactively, rather than letting it trickle in as unrelated diffs in unrelated pull requests.

What interviewers look for: a concrete decision framework, not a blanket "always use the latest features" or "never touch working code" answer, and awareness that LangVersion should follow the TFM rather than being forced, per the evolution of C#.

  • Common mistakes: treating every new feature as equally worth mandating; ignoring the review and training cost of a sweeping style change.
  • Follow-up questions: How would you introduce required members on a public API without a breaking change for existing consumers? (Add it in a new major version, or provide an attributed constructor that satisfies the requirement for existing call sites.)

Quick-Fire Round#

QuestionAnswer
Which C# version added default interface methods?C# 8, requiring .NET Core 3.0 or later at runtime
What does ^0 mean for an Index?One past the last element; indexing with it throws
Does slicing an array with a Range allocate?Yes, it copies into a new array; span slicing does not
Can a positional record parameter be marked required?No; redeclare the property explicitly to add required
Do primary constructor parameters become fields automatically?No, only if referenced outside a field or property initializer
What attribute lets a custom type use [a, b, c] syntax?CollectionBuilderAttribute, with a static Create method
How many quotes start a raw string literal?At least three, more if the content contains longer quote runs
Can extension members declare fields?No; attached state still needs something like ConditionalWeakTable
What warning flags an existing field identifier colliding with C# 14?CS9258

How to Prepare#

  • Read the generated-member list for records and positional syntax until you can recite it without looking, including EqualityContract.
  • Practice explaining required and SetsRequiredMembersAttribute with a concrete inheritance example, not just the one-line definition.
  • Build a small primary-constructor class hierarchy yourself and reproduce the duplicated-parameter-storage warning; seeing it once makes it unforgettable.
  • Know which features need which target framework, and be ready to say why forcing LangVersion ahead of the TFM is unsupported.
  • Prepare one story about deliberately not adopting a new feature immediately, and why, since interviewers value judgment over feature trivia.