20. Nullable Reference Types
NRT (Nullable Reference Types), introduced in C# 8, marks "could be null" in the type system to catch NullReferenceException at compile time.
What you'll learn
- 1Know the difference between `string` and `string?`
- 2Understand the effect of `#nullable enable` / `<Nullable>enable</Nullable>`
- 3Handle null safely with `?.`, `??`, `??=`
- 4Know when to use and when to avoid `!` (null-forgiving)
- 5See the big picture of attributes like `[NotNullWhen(true)]`
Overview
Since C# 8, **you can attach `?` to reference types to express "may be null" in the type**. New .NET 8 projects ship with NRT on by default, catching most NullReferenceExceptions at compile time.
Core Concepts
1) `<Nullable>enable</Nullable>` β project-level switch
That one line turns NRT on. You can also use `#nullable enable` / `#nullable disable` per file.
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>When enabled:
- `string x = null;` β warning (`CS8600`)
- `string? x = null;` β OK
- `string? x = ...; x.Length;` β warning (`CS8602`)
2) `string` vs `string?`
| Notation | Meaning | Assigning null | Member access warning |
|---|---|---|---|
| `string` | non-nullable | warning | none |
| `string?` | nullable | OK | warning if not null-checked |
The goal: looking at the type tells you immediately **"can null be assigned here?"**
3) Null-safe operators
Briefly seen in lecture 03 β collected here.
string? name = null;
string display = name ?? "(none)"; // default when null
name ??= "Alice"; // assign only when null
int? length = name?.Length; // null if name is null
char? first = name?[0]; // indexing is fine4) `!` β null-forgiving operator
Says "I take responsibility β compiler, suppress your warning."
string s = Console.ReadLine()!; // assert input isn't null
Person p = null!; // placeholder, will be initialized soonOverusing it defeats the point of NRT. Use it **only when you're really sure** and as locally as possible.
5) `is null` vs `== null`
Prefer `is null`. `==` can be hijacked by a user-defined overload, but `is` is a language-level check that always behaves the same.
if (x is null) ...
if (x is not null) ...6) Nullable attributes (for libraries)
Attributes like `[NotNullWhen(true)]` help the compiler's flow analysis. `Dictionary.TryGetValue` reports the null-ness of its `out` parameter the same way.
bool TryParseName(string raw, [NotNullWhen(true)] out string? value);
// returning true tells the compiler "value is not null"Examples
Example 1 β `EnableNrt`: `string` vs `string?`
string nonNullable = "hello";
string? nullable = null;
// nonNullable = null; // CS8600 warning
// Console.WriteLine(nullable.Length); // CS8602 warning
if (nullable is not null)
{
Console.WriteLine($"Length: {nullable.Length}");
}**Output**
non = hello
null = (none)**Note:** The single line `<Nullable>enable</Nullable>` in `.csproj` makes all the difference. New .NET 8 projects have it on by default.
Example 2 β `NullForgiving`: `!` operator
string name = Console.ReadLine()!; // assert input is not null
public string Name { get; set; } = null!; // placeholder β will be set soon**Output (with input "Jisoo")**
Enter your name: Jisoo
Hi, Jisoo!
Name = Alice**Note:** `null!` is common on fields that ORM entities or DI containers will populate. Sprinkling it in plain business code defeats NRT.
Example 3 β `NullPattern`: `is null` with `??`/`??=`/`?.`
string? a = null;
if (a is null) Console.WriteLine("a is null");
string display = a ?? "(no name)";
a ??= "default";
string? upper = b?.ToUpper();**Output**
a is null
b is hello
display = (no name)
a = default
upper = HELLO
first = h**Note:** Just these four operators (`is null`, `??`, `??=`, `?.`) eliminate 95% of NullReferenceException risk.
Example 4 β `NullableAnnotation`: `[NotNullWhen(true)]`
static bool TryParseName(string raw, [NotNullWhen(true)] out string? value)
{
if (string.IsNullOrWhiteSpace(raw)) { value = null; return false; }
value = raw.Trim();
return true;
}
if (TryParseName("Alice", out string? name))
{
Console.WriteLine(name.Length); // here name is inferred as not null
}**Output**
Name length: 5
Failed: empty == null ? True**Note:** This attribute removes the need for `name!` inside the `if` block. Critical for API precision in libraries.
Example 5 β `MaybeNullPattern`: `T?` returns from "find" methods
static Person? FindByName(IEnumerable<Person> people, string name)
{
foreach (Person p in people) if (p.Name == name) return p;
return null;
}
Person? found = FindByName(people, "Alice");
int age = found?.Age ?? -1;
if (found is { Age: > 18 } adult) Console.WriteLine($"{adult.Name} is an adult");**Output**
Alice age: 30
Zoe age:
Zoe age (default -1): -1
Alice is an adult**Note:** A clear way to encode "result may not exist." The caller handles it cleanly with `?.`/`??`/pattern matching.
Full example code (src/)
src/EnableNrt/EnableNrt.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern20</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
src/EnableNrt/Program.cs
// NRT is on because .csproj has <Nullable>enable</Nullable>.
// You can also use #nullable enable at the top of the file.
string nonNullable = "hello";
string? nullable = null;
// Compiler warning: cannot assign null to nonNullable.
// nonNullable = null; // CS8600
// Variables with ? must be null-checked before use.
// Console.WriteLine(nullable.Length); // CS8602: possible null dereference
if (nullable is not null)
{
Console.WriteLine($"Length: {nullable.Length}");
}
Console.WriteLine($"non = {nonNullable}");
Console.WriteLine($"null = {nullable ?? "(none)"}");
src/MaybeNullPattern/MaybeNullPattern.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern20</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
src/MaybeNullPattern/Program.cs
// A "find" method may return no result β return type Person?.
// Callers handle with ?. / ?? safely.
List<Person> people =
[
new("Alice", 30),
new("Bob", 25),
];
Person? found = FindByName(people, "Alice");
Person? missing = FindByName(people, "Zoe");
// Safe member access β entire expression is null if the source is null.
Console.WriteLine($"Alice age: {found?.Age}"); // 30
Console.WriteLine($"Zoe age: {missing?.Age}"); // (empty)
// Default substitution.
int age = missing?.Age ?? -1;
Console.WriteLine($"Zoe age (default -1): {age}");
// Branch with pattern matching.
if (found is { Age: > 18 } adult)
{
Console.WriteLine($"{adult.Name} is an adult");
}
static Person? FindByName(IEnumerable<Person> people, string name)
{
foreach (Person p in people)
{
if (p.Name == name) return p;
}
return null;
}
internal sealed record Person(string Name, int Age);
src/NullForgiving/NullForgiving.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern20</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
src/NullForgiving/Program.cs
// null-forgiving ! : "I guarantee it, compiler β suppress the null warning."
// Console.ReadLine() returns string? β can be null on EOF.
// If you're sure console input won't be null here, ! suppresses the warning.
Console.Write("Enter your name: ");
string name = Console.ReadLine()!; // suppress null warning with !
Console.WriteLine($"Hi, {name}!");
// Another common case: late initialization (e.g. SetUp in unit tests)
Person p = GetUser();
Console.WriteLine($"Name = {p.Name}");
// Order: top-level statements β local functions β type declarations.
static Person GetUser()
{
return new Person { Name = "Alice" };
}
internal sealed class Person
{
// Will be set right after construction, but the compiler doesn't know β null! signals intent.
public string Name { get; set; } = null!;
}
src/NullPattern/NullPattern.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern20</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
src/NullPattern/Program.cs
// `is null` vs `== null` β prefer `is null`.
// Reason: == can be intercepted by user-defined overloads; is is a language-level check.
string? a = null;
string? b = "hello";
if (a is null) Console.WriteLine("a is null");
if (b is not null) Console.WriteLine($"b is {b}");
// Coalescing operators
string display = a ?? "(no name)"; // substitute when null
Console.WriteLine($"display = {display}");
a ??= "default"; // assign only when null
Console.WriteLine($"a = {a}");
// ?. chaining for member access
string? upper = b?.ToUpper();
Console.WriteLine($"upper = {upper}");
// Safe indexing
char? first = b?[0];
Console.WriteLine($"first = {first}");
src/NullableAnnotation/NullableAnnotation.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern20</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
src/NullableAnnotation/Program.cs
using System.Diagnostics.CodeAnalysis;
// [NotNullWhen(true)] tells the compiler: "when this method returns true, the out parameter is not null."
// The flow analyzer uses it to track null-ness more precisely.
if (TryParseName("Alice", out string? name))
{
// Inside this branch, name is inferred as not null β no warning on .Length.
Console.WriteLine($"Name length: {name.Length}");
}
if (!TryParseName("", out string? empty))
{
Console.WriteLine($"Failed: empty == null ? {empty is null}");
}
// Sibling attributes : [NotNull], [MaybeNull], [MemberNotNull] etc. β handy for library authors.
static bool TryParseName(string raw, [NotNullWhen(true)] out string? value)
{
if (string.IsNullOrWhiteSpace(raw))
{
value = null;
return false;
}
value = raw.Trim();
return true;
}
Common Mistakes
- Slathering `!` everywhere to silence warnings β you stop catching real null flows.
- Forgetting to initialize a `string` (non-nullable) member in the constructor β CS8618 warning. Use `required` (lecture 07) or `= ""`.
- Using `Equals(null)`/`== null` instead of `is null` β same behavior but `is null` is the recommended convention.
- Receiving a value from an old library that's NRT-off and dropping it straight into a non-nullable variable β it may be null at runtime. Receive as `?` and check.
- Using the `out` parameter of `Dictionary.TryGetValue` without a null check β the compiler already knows, so it's safe inside the `if (dict.TryGetValue(...))` branch.
Summary
- NRT expresses "reference type's null-ness" at the type level
- Turn it on with `<Nullable>enable</Nullable>` or `#nullable enable`
- Don't assign null to a non-`?` reference type (the compiler blocks it)
- Handle null elegantly with the four operators `?.`, `??`, `??=`, `is null`
- `!` is the last resort β use only when you truly take responsibility
Practice
**Practice - 20. Nullable Reference Types**
Problem 1 β Safe lookup of nullable values
- Project folder: `Homework01/`
- Key concepts: `Dictionary<string, string?>`, `?.`, `??`
Requirements
- Build the following `Dictionary<string, string?>`.
```csharp Dictionary<string, string?> profile = new() { ["name"] = "Alice", ["email"] = null, ["city"] = "Seoul", }; ```
- Iterate over keys `["name", "email", "city", "phone"]` and print each key's length.
- If the key is missing or the value is null, length should be `0`.
- Use only the safe operators `?.` and `??` β one-liner.
Expected output
name : 5
email : 0
city : 5
phone : 0Hints
- `dict.TryGetValue(key, out string? value)` to extract.
- length = `value?.Length ?? 0`.
- For output, `key.PadRight(6)` aligns nicely.
Problem 2 β Repository returning `User?`
- Project folder: `Homework02/`
- Key concepts: `T?` returns, `is { ... }` pattern, `??`
Requirements
- Define `record User(int Id, string Name, int Age);`.
- Create a `UserRepo` class with:
- `User? FindById(int id)` β returns null when not found.
- In Main, query IDs 1, 2, 99 and print:
- Found: `"#1 Alice (30 y/o)"`.
- Missing: `"#99 (not found)"`.
- Also, among found Users keep only adults (`Age >= 18`) and print a single line: "Adults: Alice, Bob". Recommend the `is { Age: >= 18 }` pattern.
Expected output
#1 Alice (30 y/o)
#2 Bob (16 y/o)
#99 (not found)
Adults: AliceHints
- Sample data:
```csharp new User(1, "Alice", 30), new User(2, "Bob", 16), ```
- At the call site `User? u = repo.FindById(id);` β `if (u is null) ... else ...`.
- `List<User>` is cleaner than `List<User?>` when collecting only present users.
Check your answer
Try it yourself, then compare against the [`answer/`](./answer/) folder.
Answer (answer/)
homework/answer/Homework01/Homework01.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern20</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
homework/answer/Homework01/Program.cs
Dictionary<string, string?> profile = new()
{
["name"] = "Alice",
["email"] = null,
["city"] = "Seoul",
};
string[] keys = ["name", "email", "city", "phone"];
foreach (string key in keys)
{
// value is null when key is missing; it can also be null when key exists.
profile.TryGetValue(key, out string? value);
int length = value?.Length ?? 0; // handle both null cases in one line
Console.WriteLine($"{key.PadRight(6)}: {length}");
}
homework/answer/Homework02/Homework02.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CodingNow.Lecture.Modern20</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
homework/answer/Homework02/Program.cs
UserRepo repo = new();
int[] queries = [1, 2, 99];
List<User> adults = [];
foreach (int id in queries)
{
User? u = repo.FindById(id);
if (u is null)
{
Console.WriteLine($"#{id} (not found)");
}
else
{
Console.WriteLine($"#{u.Id} {u.Name} ({u.Age} y/o)");
}
// Pattern-match to collect adults only.
if (u is { Age: >= 18 })
{
adults.Add(u); // inside this branch, u is inferred as not null
}
}
Console.WriteLine("Adults: " + string.Join(", ", adults.Select(a => a.Name)));
internal sealed record User(int Id, string Name, int Age);
internal sealed class UserRepo
{
private readonly List<User> _users =
[
new(1, "Alice", 30),
new(2, "Bob", 16),
];
public User? FindById(int id)
{
foreach (User u in _users)
{
if (u.Id == id) return u;
}
return null;
}
}
Try It Yourself
cd src/EnableNrt && dotnet run
cd ../NullForgiving && dotnet run
cd ../NullPattern && dotnet run
cd ../NullableAnnotation && dotnet run
cd ../MaybeNullPattern && dotnet runNext Lecture
[21_Pattern_Matching](../21_%ED%8C%A8%ED%84%B4_%EB%A7%A4%EC%B9%AD/) β Branching one notch more elegant with `is`/`switch` patterns.
All lecture materials and example code are openly available on GitHub.
View on GitHub β