If I had a nickel for every time I saw int? treated like string?, I could probably fund my coffee habit. Nullable value types look friendly, but under the hood they have their own rules. Treat them like references and they will absolutely prank you in production. Let’s take a few minutes to demystify Nullable<T> with a tour of how it is represented at runtime, what happens during boxing, how operator lifting works, and the common traps that bite even experienced C# devs.

The real shape of Nullable

T? is syntax sugar for System.Nullable<T>, which is a struct with two fields in spirit: a bool HasValue and a T Value. It preserves value semantics while adding an explicit notion of absence.

Before we do anything fancy, let us get our footing.

int? hp = null;
Console.WriteLine(hp.HasValue); // False
Console.WriteLine(hp.GetValueOrDefault()); // 0
int? shields = 100;
Console.WriteLine(shields.HasValue); // True
Console.WriteLine(shields.Value); // 100

Reading .Value when HasValue is false throws InvalidOperationException, not NullReferenceException. That difference matters when you are debugging.

int? mana = null;
// Console.WriteLine(mana.Value); // Would throw InvalidOperationException
Console.WriteLine(mana ?? 50); // Use a safe fallback

Runtime representation and boxing

Here is where mental models usually go sideways. A Nullable<T> is a value type at runtime. When it is boxed, one of two things happens:

  • If it has a value, it boxes as a plain T.
  • If it is null, the boxed result is a null reference. There is never a boxed Nullable<T> instance.

Let us see that in action.

object o1 = (int?)42; // Boxes to System.Int32
Console.WriteLine(o1.GetType()); // System.Int32
object o2 = (int?)null; // Boxes to null reference
Console.WriteLine(o2 is null); // True

Unboxing follows the same rules. You can recover a nullable from a boxed value or from null.

object box = 7; // Boxed int
int? maybe = box as int?; // Lifts back to int?
Console.WriteLine(maybe.HasValue); // True
object none = null;
int? nope = none as int?; // Also fine
Console.WriteLine(nope.HasValue); // False

This is why storing int? in List<object> can surprise you later. Non-null values go in as int, null values go in as null. There is never an object whose runtime type is Nullable<int>.

Lifting rules for operators

C# lifts most operators from T to T?. The broad strokes are:

  • Arithmetic with any null operand yields null.
  • Relational comparisons with a null operand yield false.
  • Equality treats two null operands as equal.
int? a = 10;
int? b = null;
var sum = a + b; // null
var less = a < b; // false
var eq1 = a == b; // false
var eq2 = b == null; // true

For bool?, you get three valued logic. This is super handy for tri state flags.

bool? doorOpen = null;
var safe = doorOpen && true; // null
var forced = doorOpen ?? false; // false fallback

Remember that ?? returns the left operand when it has a value or the right operand otherwise. This is often safer than reaching for .Value.

int? lives = null;
int final = lives ?? 3; // 3
int alsoFine = lives.GetValueOrDefault(3); // 3

Equality and the object layer

Because non-null T? boxes to T, equality can cross layers you were not expecting.

int? x = 5;
int? y = 5;
object ox = x; // Boxed int
object oy = y; // Boxed int
Console.WriteLine(ox.Equals(oy)); // True by int.Equals

If the nullable is null, the boxed value is null. Treat it accordingly.

int? z = null;
object oz = z; // null reference
Console.WriteLine(oz == null); // True

A subtle footgun is GetValueOrDefault() with no parameter. It returns the default of T when there is no value, which can be indistinguishable from a real value if zero is meaningful.

int? score = null;
Console.WriteLine(score.GetValueOrDefault()); // 0, but there was no value
Console.WriteLine(score == 0); // False, absence is not zero

Prefer an explicit fallback that communicates intent.

int? score2 = null;
int displayed = score2 ?? -1; // Make absence visible

Pattern matching and nullability checkpoints

Pattern matching makes working with T? much more readable and avoids accidental exceptions.

int? level = 7;
if (level is int n)
Console.WriteLine($"Level {n}");
else
Console.WriteLine("No level");

The is int n pattern both checks and unwraps in one move. You can use is not null too, which maps directly to HasValue for T?.

int? credits = null;
if (credits is not null)
Console.WriteLine("You can shop");

Generics with value option types

T? works in generics when T is a struct. This gives you an ergonomic option type without bringing in new dependencies.

static T? FirstOrNone<T>(IEnumerable<T> items) where T : struct
{
foreach (var it in items) return it;
return null;
}

Using it is straightforward.

var maybeAnswer = FirstOrNone(new[] { 42 });
Console.WriteLine(maybeAnswer.HasValue); // True

If you need an option over a reference type, use T plus null or a dedicated option type. Nullable<T> only supports struct.

Common traps and simple guardrails

  • Do not treat int? like string?. Reference nullability is a compile time flow analysis feature. Nullable<T> is a runtime value container.
  • Avoid .Value unless you just checked HasValue. Prefer ??, GetValueOrDefault(x), or pattern matching.
  • Be mindful when storing T? in object collections. Non null values become boxed T, nulls become null.
  • Remember arithmetic with any null yields null. Comparison with null yields false. Equality with two nulls yields true.
  • Communicate intent with explicit fallbacks so future you will not have to reverse engineer whether zero means none.

Wrap up

Nullable<T> is a precise tool for representing absence in value types. It is not a second kind of reference null. Understand the runtime shape, remember how boxing behaves, lean on lifted operators, and you will sidestep the sneaky bugs.