📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is a record struct in C# 10, and how does it differ from a record class and a plain struct? Explain readonly record struct, the mutability default, generated equality versus ValueType.Equals, with expressions on structs, the default value problem, and when a record struct is the right choice.

record struct and readonly record struct: semantics, equality performance, default values and when to choose them.

Mid-Level Theory Intermediate equality performance record-struct structs value-types

🎯 Key points

  • record struct (C# 10) is a value type with generated equality, ToString and Deconstruct
  • Positional properties are get; set; unless the struct is declared readonly record struct
  • Generated equality is strongly typed, avoiding ValueType.Equals reflection and boxing
  • Record structs have no EqualityContract and no inheritance
  • with expressions work on all structs from C# 10
  • default(T) bypasses constructors and validation, so design for it
  • Good uses: strongly typed ids, small value objects and dictionary keys
  • Keep structs small, roughly 16 bytes or less, and measure larger ones

⏱ 30-second answer

A record struct (C# 10) is a value type with record features: generated Equals, GetHashCode, ==, ToString and, for positional ones, Deconstruct. Unlike a record class, its positional properties are mutable (get; set;) by default; readonly record struct makes them init and the struct readonly, which is what you usually want. Its generated equality compares fields directly, so it avoids the reflection and boxing that a plain struct's inherited ValueType.Equals can fall back to. It has no EqualityContract and no inheritance. Watch the default value: default(OrderId) bypasses constructors and validation. Use it for small, immutable identifiers and values (ids, coordinates, money) used in hot paths or as dictionary keys.

🗣 Full answer (senior)

How I would answer this in an interview

A record struct (C# 10) is a value type with record features generated: value equality via IEquatable<T>, ==/!=, ToString, Deconstruct and with. It combines the no-allocation benefit of structs with fast, correct generated equality. I'd compare it with record classes and plain structs, explain readonly record struct, the mutability default, with, the default value problem, and when it's the right choice.

1. Declaring

public record struct Point(int X, int Y);                 // mutable positional properties (get; set;)
public readonly record struct OrderId(Guid Value);        // immutable (get; init;), readonly struct
public readonly record struct Money(decimal Amount, string Currency)
{
    public static Money Aud(decimal amount) => new(amount, "AUD");
}

2. Versus record class and plain struct

record (class) record struct plain struct
Kind Reference type Value type Value type
Allocation Heap per instance Inline (none by itself) Inline
Positional properties init set (mutable) unless readonly N/A
Equality Generated, includes EqualityContract Generated IEquatable<T>, field-wise ValueType.Equals (reflection/bitwise, boxes)
== / != Generated Generated Not available unless you define it
GetHashCode Generated Generated (all fields) ValueType.GetHashCode (may use only first field)
ToString Generated Generated Type name
Inheritance Records only No No
Null Can be null Can't (use T?) Can't
with Yes Yes Yes (C# 10 for any struct)

3. readonly record struct and the mutability default

A plain record struct's positional properties are mutable ({ get; set; }) — chosen for consistency with tuples and struct conventions. That reintroduces mutable-struct bugs (mutating copies; changing hash codes of dictionary keys). readonly record struct makes positional properties init-only and the struct readonly, which:

  • guarantees immutability,
  • avoids defensive copies when passed by in or stored in readonly fields,
  • makes it safe as a dictionary key.

My default is readonly record struct.

4. Generated equality vs ValueType.Equals

A plain struct's Equals(object) is ValueType.Equals: it boxes the argument and, if the struct contains references or padding, uses reflection to compare fields — slow; GetHashCode may only hash the first field, causing poor distribution. A record struct generates strongly typed Equals(T) using EqualityComparer<TField>.Default per field and a full GetHashCode — fast, no boxing, good hashing. That's the main reason to prefer record structs (or hand-written IEquatable<T>) for value types used in collections.

5. with expressions on structs

var p2 = p1 with { Y = 10 };   // copies the struct (by value), then sets Y

For structs, with is just a value copy plus property sets — cheap, no allocation. Since C# 10 with works on any struct, not just record structs.

6. The default value problem

default(OrderId) is OrderId(Guid.Empty) and default(Money) is Money(0, null) — created without running any constructor: from default, array elements, uninitialised fields, missing JSON properties, EF Core key sentinels. Positional parameters can't prevent it. Mitigations:

  • Treat the zero value as valid (e.g. Money.Zero), or provide IsDefault/IsEmpty checks.
  • Validate at boundaries (API input, factory methods).
  • For strongly typed IDs, configure EF Core sentinels/value converters and reject Guid.Empty in validation.
  • Avoid reference-type fields that must be non-null, or handle null (e.g. Currency ?? "AUD").

7. When a record struct is the right choice

  • Strongly typed IDs (CustomerId, OrderId) — no allocation, value equality, prevents mixing IDs.
  • Small value objects (≤ ~16–32 bytes): coordinates, DateRange of DateOnlys, Money (decimal + short code).
  • Composite dictionary keys in hot paths ((Region, Year) with names).
  • High-volume data in arrays/spans (telemetry readings).

Not a fit: large data (copy cost), types needing inheritance, types frequently boxed (interfaces/object), or values whose default state is dangerous and can't be handled.

8. A production example

An order system passed IDs as raw Guids, and a bug passed a customerId where an orderId was expected — the query returned nothing and a refund silently failed. Introducing readonly record struct OrderId(Guid Value) and CustomerId(Guid Value), with EF Core value converters and System.Text.Json converters, made such mix-ups compile errors with no allocation overhead. We added a validation rule rejecting default IDs at API boundaries.

🔍 What the interviewer is checking

The interviewer checks your understanding of value types and record features together.

Green flags

  • Knows record structs are mutable unless readonly.
  • Explains the ValueType.Equals cost and why generated equality is better.
  • Mentions the default value problem.
  • Gives good use cases such as strongly typed ids and dictionary keys.

Red flags

  • Thinks record struct is a record class allocated on the stack.
  • Recommends large record structs for speed.
  • Ignores default(T).

📚 Learn in depth

1. Generated members for readonly record struct Money(decimal Amount, string Currency)

public readonly struct Money : IEquatable<Money>
{
    public Money(decimal Amount, string Currency) { this.Amount = Amount; this.Currency = Currency; }
    public decimal Amount { get; init; }
    public string Currency { get; init; }
    public bool Equals(Money other) =>
        EqualityComparer<decimal>.Default.Equals(Amount, other.Amount) &&
        EqualityComparer<string>.Default.Equals(Currency, other.Currency);
    public override bool Equals(object? obj) => obj is Money m && Equals(m);
    public override int GetHashCode() => HashCode.Combine(Amount, Currency);   // conceptually
    public static bool operator ==(Money l, Money r) => l.Equals(r);
    public static bool operator !=(Money l, Money r) => !l.Equals(r);
    public override string ToString() { ... }
    public void Deconstruct(out decimal Amount, out string Currency) { ... }
}

No EqualityContract (no inheritance), no copy constructor/Clone (struct copy semantics).

2. Struct layout and size

Money = 16 bytes (decimal) + 8 bytes (string reference) = 24 bytes — copying is cheap but not free; pass by value is fine for most uses. OrderId(Guid) = 16 bytes — ideal.

3. EF Core and strongly typed IDs

builder.Property(o => o.Id).HasConversion(id => id.Value, value => new OrderId(value));

Or convention-based converters via ConfigureConventions. For keys generated by the database, configure value generation and sentinel values appropriately. Libraries like StronglyTypedId and Vogen generate IDs with converters (and can enforce non-default creation).

4. JSON

System.Text.Json serialises record structs as objects ({"value":"..."}) by default; for IDs you usually want a custom JsonConverter<OrderId> to serialise as a plain string/GUID.

5. Boxing pitfalls

Record structs implement interfaces (IEquatable<T>, possibly your own). Passing them as interface types or object boxes them. Use generics with constraints in hot paths.

Try it yourself

  1. Benchmark Dictionary<PlainStruct, int> vs Dictionary<RecordStruct, int> lookups.
  2. Mutate a non-readonly record struct stored in a List<T> via indexer and observe the compile error / copy issue.
  3. Create default(OrderId) and an array of IDs and inspect values.
  4. Add EF Core and JSON converters for a strongly typed ID.

💻 Code

public readonly record struct CustomerId(Guid Value)
{
    public static CustomerId New() => new(Guid.NewGuid());
    public bool IsEmpty => Value == Guid.Empty;     // default(CustomerId) must be handled
    public override string ToString() => Value.ToString("N")[..8];
}

public readonly record struct PriceKey(int ProductId, string Region);

public record struct MutablePoint(int X, int Y);   // positional properties are get; set;

public struct PlainKey                            // no Equals: falls back to ValueType.Equals
{
    public int ProductId;
    public string Region;
}

public static class Program
{
    public static void Main()
    {
        var prices = new Dictionary<PriceKey, decimal>
        {
            [new PriceKey(42, "NSW")] = 19.95m,
            [new PriceKey(42, "WA")] = 21.50m,
        };
        Console.WriteLine(prices[new PriceKey(42, "WA")]);   // fast, allocation-free equality

        var p = new MutablePoint(1, 2);
        p.X = 10;                                             // allowed: not readonly
        var q = p with { Y = 20 };                            // with works on structs
        Console.WriteLine($"{p} {q}");

        CustomerId[] ids = new CustomerId[2];                 // default values, constructor never ran
        Console.WriteLine(ids[0].IsEmpty);                    // True

        var (product, region) = new PriceKey(7, "VIC");      // Deconstruct generated
        Console.WriteLine($"{product} {region}");
    }
}

⚠ Common mistakes

1. Using mutable record struct

What happens: positional record structs mutated after creation. Why it is wrong: mutable struct bugs and changing hash codes. Do instead: readonly record struct.

2. Large record structs

What happens: many fields in a record struct. Why it is wrong: copying cost on every assignment and call. Do instead: record classes for larger data.

3. Ignoring the default value

What happens: default(OrderId) flows into queries or saves. Why it is wrong: invalid IDs bypass constructors. Do instead: validate at boundaries; configure EF sentinels.

4. Boxing via interfaces

What happens: record structs passed as object or interfaces. Why it is wrong: allocations negate struct benefits. Do instead: generics with constraints.

5. Plain structs as dictionary keys

What happens: structs without IEquatable<T> used as keys. Why it is wrong: slow reflection equality and poor hashing. Do instead: record structs or IEquatable<T> implementations.

6. Expecting inheritance

What happens: trying to derive record structs. Why it is wrong: structs can't inherit. Do instead: composition or record classes.

7. Missing serialisation converters for IDs

What happens: IDs serialised as {"value": ...} objects. Why it is wrong: awkward API contracts. Do instead: custom JSON converters.

8. Using raw primitives for IDs

What happens: Guid for every ID. Why it is wrong: IDs can be mixed up silently. Do instead: strongly typed readonly record struct IDs.

🏛 Senior insight: trade-offs

Record structs make it cheap to do two things that senior engineers care about: type safety for primitives and allocation-free value semantics. Strongly typed ids prevent a whole class of argument-swap bugs at zero run-time cost.

Need Choice
Value object, may be null, inheritance record class
Small immutable value, hot path, dictionary key readonly record struct
Tuple-like temporary grouping value tuple
Large data with identity class

The trade-off is integration work: serialisers, EF Core and model binding need converters for custom id types. A senior answer mentions that cost and plans for it (a shared converter library) rather than discovering it per project.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate talks about value semantics and copying. Why: Checks struct and ref struct fundamentals.
    How to answer

    A struct is a value type: a variable holds the data itself, assignment and parameter passing copy it, and it is stored inline (in a local, a field or an array element) rather than as a separate heap object. Structs cannot inherit (they are sealed) but can implement interfaces. default(T) always gives all-zero fields, even when you write a parameterless constructor (allowed since C# 10). readonly struct guarantees immutability and avoids defensive copies. ref struct (like Span<T>) can live only on the stack; since C# 13 it can implement interfaces and be used with allows ref struct generics. Converting a struct to object or an interface boxes it. Mutable structs cause lost updates because you often change a copy.

  2. When: Candidate mentions EqualityContract or inheritance. Why: Compare with record classes.
    How to answer

    A record (record class) is a reference type for which the compiler generates value-based equality (Equals, GetHashCode, ==, IEquatable<T>), a virtual EqualityContract so a base and derived record are never equal, ToString via PrintMembers, a copy constructor used by with, and, for positional records, init properties and Deconstruct. with is a shallow copy: nested objects and lists are shared. Equality compares members with EqualityComparer<T>.Default, so a List<T> member is compared by reference, which breaks the "value" idea. Records fit DTOs, messages, events and value objects; they are a poor fit for EF Core entities, which need identity, not value equality, and are tracked by reference.

  3. When: Candidate mentions boxing in dictionary keys. Why: ValueType.Equals through object boxes the struct.
    How to answer

    Boxing wraps a value type in a new heap object so it can be used as object or an interface; unboxing checks the object's type and copies the value back, and it must be the exact type or you get InvalidCastException. Every box is a heap allocation, so in hot paths it creates GC pressure. Hidden boxing comes from non-generic APIs like ArrayList and params object[], passing structs as interfaces, calling GetType() or non-overridden Equals/GetHashCode on structs, and enumerating a List<T> through IEnumerable<T>. I find it with BenchmarkDotNet's MemoryDiagnoser or an allocation profiler and fix it with generics, constraints and IEquatable<T>.

🛠 Real-world problems: how you handle them

Plain struct dictionary key caused allocations in a pricing engine
Situation
A pricing engine computed [5 million] price lookups per batch using a Dictionary keyed by a plain struct (ProductId, Region) with no Equals override.
Signals
High Gen 0 GC counts in dotnet-counters, and batch time of [90] seconds against a target of [30].
Investigation
A CPU and allocation profile showed ValueType.Equals using reflection over fields (the struct had a string field) and boxing on each comparison.
Resolution
Changed the key to a readonly record struct, which generated IEquatable and a proper hash code.
Prevention
Added an analyser rule (CA1815) at warning level for structs without equality overrides, and a benchmark for the lookup path.

How to tell it (STAR)

**Situation:** our nightly pricing batch took [90] seconds and needed to fit a [30] second window. **Task:** I was asked to find quick wins without redesigning it. **Action:** I profiled it and found the dictionary key was a plain struct whose equality fell back to reflection and boxing. I changed it to a readonly record struct and enabled the CA1815 rule for other structs. **Result:** allocations in the lookup path dropped to zero and the batch ran in [28] seconds, with no logic changes.

↩ Usually asked after

More in Modern C#

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me