Value type or reference type is not really "stack vs heap". It is what gets copied when you assign — and almost everything else (storage, nullability, boxing) follows from that one fact.
value type: int, double, bool, DateTime, any struct, any enum, Guid
reference type: any class, string, arrays, delegates, interface, record class
The whole model is one rule: assignment copies the variable. For a struct that means every field. For a class that means the address only — which is why two class variables can quietly share one object.
stack is fast, small (~1MB per thread), and automatic.
heap is large and managed by the garbage collector (GC).
A value type is not "always on the stack" — that is the tempting oversimplification. Value types live wherever their container lives:
async or an iterator → lifted onto the heap in a compiler-generated class"Value type" describes copy semantics. Where it is stored is an implementation detail the runtime picks.
class.struct when the type is small (rule of thumb: ≤16 bytes), represents a single value conceptually (a money amount, a coordinate, a time range), and is immutable.
readonly.Equality defaults differ too, and it is worth knowing before you drop a type into a HashSet or Distinct():
struct → field-by-field equality, but the default uses reflection unless you override Equals/GetHashCode.class → reference equality unless you override it.record struct / record → value equality generated for you — the reason records are the default for DTOs.int is always some number. But real domains need "absent": an employee's TerminationDate while they are still employed.int?, DateTime? — shorthand for Nullable<T>, a struct wrapping a value plus a HasValue flag.
Nullable<T> itself is never null. It is a struct that carries whether it currently holds a value.int? x; → the compiler treats it as Nullable<int> x;. The struct isn't null; it simply reports "I don't currently contain a valid int."// simplified — the real Nullable<T> also has GetValueOrDefault(), etc.
public struct Nullable<T> where T : struct
{
private bool hasValue;
private T value;
public bool HasValue => hasValue;
public T Value
{
get
{
if (!hasValue)
throw new InvalidOperationException();
return value;
}
}
}
// int? x = 5; int? x = null;
// ----------------- -----------------
// HasValue = true HasValue = false
// Value = 5 Value = 0 (ignored)NullReferenceException.
string means "never null, I promise"; string? means "might be null, callers must check."default is the keyword for "the default value of this type", and it ties the two worlds together:
DateTime d = default; // 01/01/0001 12:00:00 AM
bool b = default; // false
int x = default; // 0
int? y = default; // nullint? y = default lands on null because Nullable<int>'s default state is HasValue = false (with Value = 0 ignored).
object (or interface) is expected. The runtime:
int hours = 40;
object boxed = hours; // boxing: allocation + copy
int unboxed = (int)boxed; // unboxing: cast + copyList<int> knows it holds ints, so nothing needs to become object. (This is the bridge into Day 2.)ToString() is fine, but passing a struct into something typed as object — old APIs, some logging overloads, non-generic collections — still boxes today.The aliasing trap and immutability cancel out in Money { decimal Amount; string Currency }: copying a Money shares the one string reference, but strings are immutable, so no one can mutate the shared object — the Day-1 bug can't fire. That is the whole model clicking together in one type. Also: readonly struct + in parameters remove the defensive copies the compiler otherwise inserts — measure before reaching for them.