When would you make a type a record, a struct, or a class? What does each choose for you?
A class gives reference identity, a struct gives value semantics and lives inline, and a record is a class with value-based equality and a copy-with-changes syntax generated for you. The decision is about identity and copying cost, not about which one is newest.
What the interviewer is scoring
- Whether the candidate separates equality semantics from where the data lives
- That a record is a class by default rather than a value type
- Does the candidate know when a struct stops being cheap and why
- Whether the shallow nature of a with expression and of record equality is raised
- That mutable structs are identified as a trap rather than a style preference
Answer
Two independent choices, not three options
The question looks like a menu and is really two switches.
The first is where the data lives and how it is passed: a reference type is
allocated on the heap and passed as a reference, a value type is copied on
assignment and lives inline in whatever contains it. class and struct set
this.
The second is what equality means: reference equality, meaning the same
object, or value equality, meaning the same contents. record sets this, and it
also generates a constructor, deconstruction, a readable ToString, and the
with expression.
They compose, which is why there are four combinations rather than three
options. A record is a class unless you write record struct, which surprises
people who assume the word means "value type".
| Declaration | Lives | Equality | Typical use |
|---|---|---|---|
class | Heap, by reference | Reference | An entity with identity and a lifetime |
record | Heap, by reference | Value, member-wise | A DTO, a message, an immutable domain value |
struct | Inline, copied | Value, but slow by default | Small numeric-ish values, hot paths |
readonly record struct | Inline, copied | Value, generated | Small immutable values where allocation matters |
Choose by identity first
The question that decides it is whether two instances with identical contents are the same thing.
An Order has identity: two orders with the same amount and customer are two
orders, and mutating one must not appear to change the other. That is a class.
Giving it record equality would make a dictionary of orders behave strangely and
would make Equals traverse every field on a type whose fields change.
A Money, a DateRange, a Coordinate, a parsed request body — these have no
identity. Two with the same contents are interchangeable, and the code reads
better when the language agrees. That is a record.
Once identity says value, the second question is size. Records are the default answer; structs earn their place when the type is small and allocated in quantity.
What a record actually generates
public record Money(decimal Amount, string Currency);
var a = new Money(25.00m, "GBP");
var b = new Money(25.00m, "GBP");
a == b; // true - member-wise equality, generated
a.Equals(b); // true
ReferenceEquals(a, b); // false - still two heap objects
var c = a with { Amount = 30.00m }; // a is unchanged; c is a new instance
Console.WriteLine(c); // Money { Amount = 30.00, Currency = GBP }
The generated Equals and GetHashCode are the point. Writing those by hand is
the most-skipped and most-wrongly-implemented pair in the language, and getting
them inconsistent breaks every dictionary and set the type is used in.
The two shallow behaviours
Both generated features are shallow, and this is the detail interviewers reach for.
with copies the fields, so a reference field points at the same object in both
copies. A record holding a List<string> produces a "copy" that shares the
list, and mutating it through either reference changes both.
Equality compares fields with their own Equals, so two records holding
different arrays with identical contents are not equal — arrays use
reference equality, and the generated code does not know you meant otherwise.
public record Basket(string Id, string[] Items);
var x = new Basket("b1", new[] { "apple" });
var y = new Basket("b1", new[] { "apple" });
x == y; // false. Same contents, different arrays.
The fix is to hold an immutable collection and compare it deliberately, or to
override Equals for that member. The trap is that the type looks immutable and
value-like, so the behaviour is assumed rather than checked.
When a struct stops being cheap
A struct avoids an allocation and improves locality, which is genuinely valuable in an array of a million of them. It also gets copied on every assignment, every argument pass and every return, so those savings invert as the type grows. The conventional guidance is to keep a struct small, immutable and logically a single value.
Two specific hazards are worth naming.
Mutable structs behave unexpectedly, because you frequently mutate a copy. Retrieving a struct from a list gives you a copy; setting a field on it changes nothing in the list, and the compiler often will not stop you.
Boxing undoes the benefit. Putting a struct into an object, into a
non-generic collection, or into an interface allocates a heap object and copies
it there. A struct implementing an interface and used through that interface
is allocating on every call, which is the opposite of the reason it was a
struct.
readonly record struct is the modern shape that avoids most of this: value
semantics, generated equality, no allocation, and the compiler enforcing that
nothing mutates.
The recognisable wrong answers
Two answers date a candidate. "Records are the new way, so use records for everything" ignores identity, and produces entities whose equality traverses mutable state. "Structs are faster" ignores copying, and produces a sixteen-field struct passed by value through five layers.
The answer an interviewer is listening for names the trade rather than the preference: identity decides class against record, size and allocation volume decide class against struct, and both generated behaviours stop at the first reference.
A record is a class that knows what equality means. A struct is a value that gets copied whether or not you wanted it to.
Likely follow-ups
- What does `with` copy, and what does it leave shared?
- Why is a large mutable struct in a list a performance problem?
- Two records hold the same array. Are they equal?
- When would you write `readonly record struct`, and what does each word buy?
Related questions
- What is the difference between a value type and a reference type in C#, and what do nullable reference types guarantee?mediumAlso on value-types and structs5 min
- A client disconnects but your server keeps working on their request. How does cancellation actually propagate in .NET?mediumAlso on csharp4 min
- How do records, sealed types and pattern matching change the way you model a domain?mediumAlso on records5 min
- How would you design a thread-safe component, and why is adding synchronized to every method not a design?hardSame kind of round: concept7 min
- How do you tell whether a value escapes to the heap, and how would you find the allocations that are costing you?hardSame kind of round: concept6 min
- Adding a second LEFT JOIN doubled the revenue figure on a report. Explain what happened and write the correct query.mediumSame kind of round: coding4 min
- How do you decide between BFS and DFS - and how do you recognise a graph problem that nobody described as a graph?mediumSame kind of round: coding4 min
- Count the subarrays whose elements sum to k, where the values may be negative.mediumSame kind of round: coding4 min