What is the difference between a value type and a reference type in C#, and what do nullable reference types guarantee?
A value type stores its data inline wherever it is declared and is copied on every assignment, while a reference type stores a pointer to a heap object. Nullable reference types are compile-time flow analysis that emits warnings and is erased before execution, so they guarantee nothing at runtime.
What the interviewer is scoring
- Does the candidate say where a value type lives based on what declares it, rather than reciting "structs go on the stack"
- Whether copy-on-assignment is presented as the defining semantics of a struct, not as a performance footnote
- That they can produce a concrete case where a struct copy silently discards a mutation
- Whether they state plainly that nullable reference types emit warnings and insert no runtime check
- Does the candidate name a real hole — deserialisation, arrays, unannotated libraries — instead of describing the feature as null safety
Answer
Two kinds of type
A value type — every struct, every enum, the built-in numeric types, bool,
char, tuples, and Nullable<T> — stores its data directly in the storage location
that declares it. A reference type — every class, interface, delegate, array,
and string — stores a reference to an object allocated on the managed heap, and the
reference itself is what lives in the declaring location.
That phrasing matters, because the rule most candidates recite is wrong. "Value types live on the stack" is true only for local variables, and locals are a minority of the places values live. Ask instead what declares the storage:
- A local of value type sits in the current stack frame or in a register.
- A value-type field of a class sits inside that class's heap object. A
struct Pointfield of an entity is heap memory, contiguous with the rest of the object. - A value type in an array sits inline in the array's heap allocation, which is the
interesting property:
Point[1000]as a struct is one contiguous allocation, whereas as a class it is 1000 objects plus an array of pointers. That is why struct arrays are so much friendlier to the CPU cache. - A boxed value type is a heap object — assigning a struct to
objector to an interface-typed variable allocates a box and copies into it. - A value type captured by a lambda, or held across an
await, is hoisted into a compiler-generated class and ends up on the heap.
The stack is an implementation detail the runtime may change. The observable difference, and the one the language defines, is copy semantics.
Copying is the semantics, not a detail
Every assignment of a value type copies every field. So does passing it to a method, returning it, storing it in a field, adding it to a collection, or enumerating a collection of them. A reference type assignment copies one pointer, and both names then see the same object.
This is where mutable structs cause real bugs, and the compiler stops you in some places and not others.
public struct Counter
{
public int Value;
public void Increment() => Value++;
}
var array = new Counter[1] { new Counter() };
array[0].Increment(); // works: an array element access IS a variable
Console.WriteLine(array[0].Value); // 1
var list = new List<Counter> { new Counter() };
// list[0].Increment(); // CS1612: the indexer returns a COPY, not a variable
Console.WriteLine(list[0].Value); // still 0 if you route around the error
foreach (var c in list) c.Increment(); // mutates a copy per iteration
Console.WriteLine(list[0].Value); // still 0 — and no warning
List<T>'s indexer is a method returning a value, so mutating the result would mutate
a temporary and the compiler refuses. An array element is a real storage location, so
the same expression compiles and behaves differently. The foreach case is the
dangerous one: it compiles, runs, and does nothing, because the iteration variable is a
fresh copy each time.
readonly struct fixes the whole family, and it also removes hidden copies. Calling a
method on a readonly field of struct type, or on an in parameter, forces the
compiler to make a defensive copy in case the method mutates what it promised not to —
which is why in parameters on ordinary mutable structs sometimes make code slower
rather than faster.
Nullable reference types are a warning system
Nullable reference types, introduced in C# 8 and enabled with <Nullable>enable</Nullable>
or #nullable enable, change how the compiler reads reference-type declarations. string
now means "not intended to be null" and string? means "may be null", and a flow analysis
tracks whether each reference is in a not-null or maybe-null state at each point, warning
when you dereference something that might be null.
What it does not do is emit a single runtime check. The annotations are erased into metadata attributes so other compilations can read them; nothing in the produced IL tests for null on your behalf. And the diagnostics are warnings, so unless the project turns them into errors, code violating every annotation still builds and ships.
That leaves four categories of hole, all of which appear in real systems.
Anything that constructs objects without running your constructors. A JSON
deserialiser sets properties by reflection, and a payload with a missing field leaves a
non-nullable string property holding null. The type says it cannot be null; the
instance in front of you is.
#nullable enable
public class Order
{
public string CustomerId { get; set; } = null!; // "trust me" — and nothing checks
}
// Deserialising {"other":1} yields an Order whose CustomerId is null,
// with no warning at the call site and no exception until something reads it.
Defaults and arrays. new string[10] is an array of ten nulls, correctly typed as
non-nullable string[], because the annotation describes the element type and the
runtime zeroes the storage. default(T) for a struct with reference fields does the
same to every one of them.
The suppression operator. ! tells the compiler to stop asking. Occasionally you
do know something the flow analysis cannot see; more often it is used to silence a
warning that was correct. A codebase where ! is common has the annotations without the
benefit.
Unannotated code. A library compiled without the feature is treated as oblivious, producing no warnings in either direction. Interop, older packages and generated code all land here.
The distinction that separates a good answer
Candidates conflate string? with int?, and they are not the same mechanism at all.
Nullable<T> is a real struct with HasValue and Value, present at runtime, with
defined boxing behaviour — box a Nullable<int> with no value and you get a null
reference rather than a box containing nothing. string? is the identical type as
string in the emitted assembly, distinguished only by an attribute. One is a runtime
feature; the other is a static analysis wearing similar syntax, which is exactly why one
guarantees something and the other cannot.
A struct is defined by being copied and a class by being shared, and where either lives follows from what declares it. Nullable annotations are the compiler's opinion about your intent, enforced at build time and erased before your code ever runs.
Likely follow-ups
- When would you make a type a struct rather than a class, and what would you measure to justify it?
- What does readonly struct change, and what does the in parameter modifier do without it?
- Your API returns a record with a non-nullable string property and a client receives null. How did that happen?
- How does Nullable<T> differ from a nullable reference type in what the compiler emits?
Related questions
- When would you make a type a record, a struct, or a class? What does each choose for you?mediumAlso on structs and value-types4 min
- Memory keeps climbing in a Go service under load. How do you find out why, and what can you actually tune?hardAlso on memory5 min
- How do generators reduce memory use, and when are they the wrong choice?mediumAlso on memory4 min
- A long-running Python service keeps climbing in memory and never comes back down. How do you work out where the memory went?hardAlso on memory5 min
- How does the .NET garbage collector decide what to collect, and how would you make a hot path allocate less?hardAlso on memory6 min
- A client disconnects but your server keeps working on their request. How does cancellation actually propagate in .NET?mediumAlso on csharp4 min
- How would you rewrite a row-by-row pandas transformation, and what is SettingWithCopyWarning telling you?mediumAlso on memory5 min
- A hot path allocates heavily and garbage collection is showing up in your profile. What does Span give you that a substring does not?hardAlso on memory4 min