Nullable Reference Types
Reviewed & published by Brayan K
Introduced in C# 8, nullable reference types let the compiler catch the most common bug in .NET — the dreaded NullReferenceException — before your program ever runs. By annotating types as string (never null) or string? (may be null), you give the compiler enough information to warn you exactly where a value might be null. It's a compile-time safety net, not a runtime change.
Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Enable NRTs with #nullable enable or the .csproj setting
- Tell string (non-nullable) from string? (maybe-null)
- Read compiler warnings like CS8602 and CS8600
- Use ?., ?? and ??= to handle null safely
- Apply the null-forgiving ! operator carefully
- Migrate an existing codebase incrementally
💡 Real-World Analogy
Think of a form at a doctor's office. Some fields are marked required (your name) and some are clearly optional (a middle name). If a required field is blank, the receptionist catches it at the desk — before you ever see the doctor. Nullable reference types are exactly that receptionist: string is a required field the compiler insists you fill, and string? is an optional one you must remember to check. The errors are caught up front, not in the middle of the appointment (at runtime).
The key idea: annotations the compiler reads
Nullable reference types are annotations plus static analysis. When the feature is on, the ? on a reference type tells the compiler "this might be null", and the absence of ? says "this should never be null". The compiler then performs null-state flow analysis and warns you whenever you risk dereferencing a maybe-null value.
- 🛡️ string — non-nullable annotation; the compiler warns if you assign null.
- ❓ string? — nullable annotation; you opt in to possible null and must check before use.
- 🔎 flow analysis — after if (x != null) the compiler narrows x to not-null in that branch.
- ‼️ ! — the null-forgiving operator suppresses a warning; a promise from you, not a runtime guard.
Crucially, none of this changes runtime behavior: at runtime string and string? are the same plain reference. This is purely a compile-time safety feature — distinct from int?, which really is a different runtime type.
1. Enabling NRTs: string vs string?
Turn the feature on with #nullable enable at the top of a file, or project-wide with <Nullable>enable</Nullable> in your .csproj. Once enabled, a plain string means "never null" and string? means "may be null". Read the worked example, run it, and notice which lines would warn.
#nullable enable
using System;
class Program
{
static void Main()
{
// Under #nullable enable, a plain 'string' is NON-nullable:
// the compiler expects it to never hold null.
string name = "Sam"; // fine
// string bad = null; // would warn CS8600: assigning null
// 'string?' opts IN to maybe-null. You must check before using it.
string? nickname = null; // allowed — the ? says "may be null"
Console.WriteLine(name.Length); // safe: name is not-null
// Console.WriteLine(nickname.Length); // would warn CS8602
if (nickname is not null)
Console.WriteLine(nickname.Length); // safe inside the check
else
Console.WriteLine("(no nickname)");
}
}
// ✅ Expected output:
// 3
// (no nickname)2. Null-State Flow Analysis
The compiler doesn't just look at declarations — it follows the flow of your code. Before a null check a variable is "maybe-null"; after a guard like if (text == null) return; the compiler narrows it to "not-null" for the rest of the method. This is why a single well-placed check silences the warning.
#nullable enable
using System;
class Program
{
static int LengthOf(string? text)
{
// At the top, 'text' is MAYBE-null. Dereferencing now warns (CS8602).
// After this guard, the compiler narrows text to NOT-null in the
// branch below — this is null-state flow analysis at work.
if (text == null)
return 0;
// The compiler now KNOWS text is not null here, so .Length is safe.
return text.Length;
}
static void Main()
{
Console.WriteLine(LengthOf("hello")); // 5
Console.WriteLine(LengthOf(null)); // 0 — handled, no crash
}
}
// ✅ Expected output:
// 5
// 0Your turn. Annotate the parameter as nullable and add a ?? fallback so the method handles null safely. Fill in the two ___ blanks.
#nullable enable
using System;
class Program
{
// 🎯 YOUR TURN — fill in the blanks marked with ___
// 1) This parameter may be null, so annotate it.
static string Greet(string___ name) // 👉 the nullable marker: ?
{
// 2) Guard against null using the ?? operator to supply a fallback.
string safe = name ___ "friend"; // 👉 the operator: ??
return "Hello, " + safe + "!";
}
static void Main()
{
Console.WriteLine(Greet("Ada")); // Hello, Ada!
Console.WriteLine(Greet(null)); // Hello, friend!
}
}3. The Null Operators: ?., ??, ??=
These three operators are your everyday tools for working with maybe-null values. ?. (null-conditional) short-circuits to null instead of throwing; ?? (null-coalescing) supplies a fallback; and ??= (null-coalescing assignment) sets a value only when the target is currently null.
#nullable enable
using System;
class Program
{
static void Main()
{
string? input = null;
// ?. null-conditional: returns null instead of throwing if input is null.
int? len = input?.Length; // len is null, no crash
Console.WriteLine(len.HasValue ? len.ToString() : "(null length)");
// ?? null-coalescing: pick a fallback when the left side is null.
string shown = input ?? "default";
Console.WriteLine(shown); // default
// ??= null-coalescing assignment: only assigns if input is null.
input ??= "now set";
Console.WriteLine(input); // now set
input ??= "ignored"; // no-op: input is already non-null
Console.WriteLine(input); // still: now set
}
}
// ✅ Expected output:
// (null length)
// default
// now set
// now setNow you try: use a guard so flow analysis narrows the value, then call a string method safely. Fill in the two ___ blanks.
#nullable enable
using System;
class Program
{
// 🎯 YOUR TURN — fill in the blanks marked with ___
static string Describe(string? city)
{
// 1) If city is null, return early so the compiler narrows it below.
if (city ___ null) // 👉 the equality operator: ==
return "unknown city";
// 2) After the guard the compiler KNOWS city is not-null, so .ToUpper()
// is safe with no warning. Just return it upper-cased.
return city.___(); // 👉 the method: ToUpper
// ✅ Expected output:
// LONDON
// unknown city
}
static void Main()
{
Console.WriteLine(Describe("London"));
Console.WriteLine(Describe(null));
}
}4. The Null-Forgiving Operator !
Sometimes you know a value isn't null but the compiler can't prove it. The trailing ! (null-forgiving operator) tells the compiler "trust me, this isn't null" and suppresses the warning. Use it sparingly: it's a promise, not a runtime check, so a wrong promise still throws a NullReferenceException at runtime. Prefer a real check whenever you can.
#nullable enable
using System;
class Program
{
static string? Lookup(string key) => key == "x" ? "found" : null;
static void Main()
{
// We KNOW "x" returns a value, but the compiler sees string? and warns.
// The trailing ! is the NULL-FORGIVING operator: it suppresses the
// warning. It is a promise, NOT a runtime check — misuse can crash.
string value = Lookup("x")!; // ! says "trust me, not null"
Console.WriteLine(value.Length); // 5
// SAFER alternative: actually check instead of forgiving.
string? maybe = Lookup("y");
if (maybe is not null)
Console.WriteLine(maybe.Length);
else
Console.WriteLine("nothing found"); // prints this
}
}
// ✅ Expected output:
// 5
// nothing found5. Null-State Attributes
Attributes from System.Diagnostics.CodeAnalysis — like [NotNull], [MaybeNull] and [NotNullWhen(true)] — describe the null-state after a method call, so flow analysis can reason about helpers such as the classic TryGet pattern. They let your APIs teach the compiler what they guarantee.
#nullable enable
using System;
using System.Diagnostics.CodeAnalysis;
class Program
{
// Post-condition attributes describe null-state AFTER a call so flow
// analysis can reason about helpers like a TryGet pattern.
static bool TryGetName(int id, [NotNullWhen(true)] out string? name)
{
if (id == 1) { name = "Grace"; return true; }
name = null; return false;
}
static void Main()
{
// Because of [NotNullWhen(true)], inside this 'if' the compiler KNOWS
// 'name' is not null — so we can use it without any CS8602 warning.
if (TryGetName(1, out string? name))
Console.WriteLine(name.Length); // safe: 5
if (!TryGetName(2, out string? missing))
Console.WriteLine("not found"); // missing stays null here
}
}
// ✅ Expected output:
// 5
// not found🔎 Deep Dive: string? is not like int?
It's easy to assume string? and int? are the same idea, but they're fundamentally different. int? is genuine syntax for the runtime type Nullable<int> — a real struct with HasValue and Value members that occupies different memory than a plain int.
string?, on the other hand, is only a compile-time annotation. At runtime there is no separate "nullable string" type — it's the same string reference, which could always hold null anyway. The ? exists purely so the compiler can warn you:
// VALUE type: int? is a real runtime struct Nullable<int>
int? count = null;
if (count.HasValue) Console.WriteLine(count.Value);
// REFERENCE type: string? is compile-time-only; at runtime it's just string
string? note = null; // same reference as plain string
if (note is not null) Console.WriteLine(note.Length);So nullable value types add runtime capability; nullable reference types add compile-time warnings. Same ? symbol, very different machinery.
Putting It Together: a User Profile Model
Here's the pattern you'll use constantly in real apps: required fields are non-nullable, optional ones are string?, and the rendering code uses ?. and ?? to handle the optional values cleanly. The annotations document your intent and get checked by the compiler.
#nullable enable
using System;
class Program
{
// A realistic model: required fields are non-nullable, optional ones are ?
class UserProfile
{
public string Username { get; init; } = ""; // required, never null
public string? Bio { get; init; } // optional, may be null
public string? Website { get; init; } // optional, may be null
}
static string Render(UserProfile p)
{
// ?? gives a friendly fallback for the optional fields.
string bio = p.Bio ?? "(no bio yet)";
// ?. safely reaches into a possibly-null value, then ?? defaults it.
int siteLen = p.Website?.Length ?? 0;
return $"@{p.Username}: {bio} | site chars: {siteLen}";
}
static void Main()
{
var full = new UserProfile { Username = "ada", Bio = "Engineer", Website = "ada.dev" };
var sparse = new UserProfile { Username = "sam" };
Console.WriteLine(Render(full)); // @ada: Engineer | site chars: 7
Console.WriteLine(Render(sparse)); // @sam: (no bio yet) | site chars: 0
}
}
// ✅ Expected output:
// @ada: Engineer | site chars: 7
// @sam: (no bio yet) | site chars: 0Notice how the model itself documents which data is guaranteed and which is optional — the type signatures are the documentation.
Pro Tips
- 💡 Enable NRTs project-wide with <Nullable>enable</Nullable> for new projects so all code benefits from day one.
- 💡 Treat warnings as errors in CI (<WarningsAsErrors>nullable</WarningsAsErrors>) so null bugs can't slip through.
- 💡 Avoid ! as a habit: reach for a real check or pattern match first; the null-forgiving operator hides risk.
- 💡 Annotate APIs honestly: return string? when a method really can return null, so callers are warned to check.
- 💡 Initialize non-nullable fields: use a default like = "" or the required modifier to avoid CS8618 on uninitialized properties.
Common Errors (and the fix)
- "CS8602: Dereference of a possibly null reference": you used a maybe-null value without checking. Add a null check, use ?., or supply a default with ??.
- "CS8600: Converting null literal or possible null value to non-nullable type": you assigned a maybe-null value to a non-nullable variable. Make the target string? or guard the assignment.
- "CS8618: Non-nullable property must contain a non-null value when exiting constructor": initialize it (e.g. = ""), mark it required, or make it nullable if it really can be null.
- Overusing !: sprinkling null-forgiving operators silences warnings but reintroduces the crashes NRTs were meant to prevent. Fix the underlying null instead.
- Confusing it with int?: remember int? is a runtime Nullable<int>; string? is compile-time-only annotation on the same reference type.
📋 Quick Reference
| Syntax | Means | Example |
|---|---|---|
| #nullable enable | Turn the feature on (per file) | top of file |
| string | Non-nullable reference | string name |
| string? | Maybe-null reference | string? note |
| ?. | Null-conditional access | note?.Length |
| ?? | Fallback when null | note ?? "n/a" |
| ??= | Assign only if null | note ??= "set" |
| x! | Null-forgiving (suppress warning) | Lookup()! |
Frequently Asked Questions
Q: Do nullable reference types stop null at runtime?
No. They're a compile-time feature. At runtime string and string? are the same reference and either can technically be null. NRTs just give you warnings so you fix the risks before shipping.
Q: How is string? different from int??
int? is a real runtime type, Nullable<int>, with HasValue/Value. string? is only a compile-time annotation on a reference type that could already hold null — there's no separate runtime type.
Q: Is the null-forgiving ! ever safe to use?
Occasionally — for example after a check the compiler can't follow, or in tests. But it's a promise, not a guard: if you're wrong, you get a NullReferenceException. Prefer a real check or pattern match.
Q: How do I add NRTs to a big existing project?
Incrementally. Turn it on file by file with #nullable enable (or per folder), fix that file's warnings, then move on — rather than enabling it everywhere and facing thousands of warnings at once.
Mini-Challenge: a Safe Display-Name Helper
No blanks this time — just a brief and an outline. Write a DisplayName(string? first, string? last) that returns "Anonymous" when both parts are missing, otherwise joins the available parts with a space using ?? and Trim(). Run it and check your output against the expected lines in the comments.
#nullable enable
using System;
class Program
{
static void Main()
{
// 🎯 MINI-CHALLENGE: a safe display-name helper
// Write a method: static string DisplayName(string? first, string? last)
// 1. If BOTH first and last are null or empty, return "Anonymous".
// 2. Otherwise join them with a space, using ?? "" to handle nulls,
// and Trim() the result so a missing part doesn't leave a stray space.
// 3. Call it with ("Ada","Lovelace"), ("Grace", null), and (null, null).
//
// ✅ Expected output:
// Ada Lovelace
// Grace
// Anonymous
// your code here
}
}🎉 Lesson Complete
- ✅ NRTs are a compile-time feature that warns about possible null dereferences
- ✅ string means non-nullable; string? means maybe-null
- ✅ Null-state flow analysis narrows a variable to not-null after a guard
- ✅ Use ?., ?? and ??= to handle null cleanly
- ✅ The null-forgiving ! suppresses a warning — a promise, not a runtime check
- ✅ It's distinct from nullable value types like int? (a real Nullable<int>)
Practice quiz
What is the main purpose of nullable reference types (NRTs) in C# 8 and later?
- To give compile-time warnings about possible null dereferences
- To make programs run faster at runtime
- To replace nullable value types like int?
- To stop null from existing at runtime entirely
Answer: To give compile-time warnings about possible null dereferences. NRTs are a compile-time feature: the compiler tracks null-state and warns where you might dereference null. They do not change runtime behavior or remove null.
With nullable reference types enabled, what does plain string (no question mark) express?
- The same as object
- A string that may be null
- A non-nullable string the compiler expects to never be null
- A value type
Answer: A non-nullable string the compiler expects to never be null. Under #nullable enable, string is the non-nullable annotation: the compiler warns if you try to assign null to it. Use string? to opt into possible null.
How do you turn the nullable reference types feature on for a whole project?
- Call Enable() at startup
- Set <Nullable>enable</Nullable> in the .csproj (or #nullable enable per file)
- Decorate Main with an attribute
- Add a using directive
Answer: Set <Nullable>enable</Nullable> in the .csproj (or #nullable enable per file). Project-wide, you set <Nullable>enable</Nullable> in the csproj. Per file you can use the #nullable enable directive.
What does the null-forgiving operator (the trailing !) do?
- Throws if the value is null
- Converts a value type to a reference type
- Marks a parameter as required
- Tells the compiler to suppress its null warning for that expression
Answer: Tells the compiler to suppress its null warning for that expression. The null-forgiving operator (x!) suppresses the compiler's null warning. It is a promise from you, not a runtime check, so it can hide a real NullReferenceException.
What is null-state flow analysis?
- The compiler tracking whether a variable is maybe-null or not-null as code flows
- A way to log nulls at runtime
- A LINQ operator
- A runtime garbage-collection pass
Answer: The compiler tracking whether a variable is maybe-null or not-null as code flows. The compiler follows the flow of your code; after a check like if (x != null) it knows x is not-null inside that branch and adjusts its warnings accordingly.
Which warning typically signals 'dereference of a possibly null reference'?
- CS1002
- CS8602
- CS0001
- CS0103
Answer: CS8602. CS8602 is the classic 'dereference of a possibly null reference' warning. CS8600 covers converting null (or a maybe-null) into a non-nullable type.
What does the ??= operator do?
- Always overwrites the left side
- Compares two values for null
- Throws when the left side is null
- Assigns the right side only if the left side is currently null
Answer: Assigns the right side only if the left side is currently null. The null-coalescing assignment ??= assigns the right-hand value only when the left-hand variable is null, a concise way to set defaults.
How are nullable reference types different from nullable value types like int?
- int? is compile-time only
- NRTs only work on int
- NRTs are compile-time annotations on reference types; int? is a real runtime Nullable<int> struct
- They are exactly the same feature
Answer: NRTs are compile-time annotations on reference types; int? is a real runtime Nullable<int> struct. int? is the runtime type Nullable<int>, a real struct. NRTs are compile-time annotations and analysis on reference types and erase to plain references at runtime.
What does the [NotNull] attribute on an out parameter communicate to the compiler?
- The parameter must always be passed null
- When the method returns, that value is guaranteed not null
- The method never returns
- The parameter is ignored
Answer: When the method returns, that value is guaranteed not null. Post-condition attributes like [NotNull] (and [MaybeNull]) describe the null-state after a call, helping flow analysis reason about helper methods such as TryGet patterns.
A pragmatic strategy for migrating a large codebase to nullable reference types is to:
- Enable it file by file with #nullable enable and address warnings incrementally
- Replace every string with string?
- Enable it everywhere and fix every warning in one commit
- Never enable it
Answer: Enable it file by file with #nullable enable and address warnings incrementally. Incremental adoption, enabling NRTs file by file (or per-folder) and resolving warnings gradually, keeps the migration manageable rather than facing thousands of warnings at once.
Continue this course
- Previous: Web UIs with Blazor
- Next: Iterators with yield return — Build lazy IEnumerable<T> sequences with yield return and yield break
- Quick reference: C# cheat sheet