← Back to C# series
✨
Modern C#
Modern C# Β· Prerequisite: OOP

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.

C#.NET 8NullableNRT
Duration
⏱ ~1-1.5 hours
Level
πŸ“Š Intermediate
Prerequisite
🎯 OOP completed
OUTCOME
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.

xml
<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?`

NotationMeaningAssigning nullMember access warning
`string`non-nullablewarningnone
`string?`nullableOKwarning 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.

csharp
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 fine

4) `!` β€” null-forgiving operator

Says "I take responsibility β€” compiler, suppress your warning."

csharp
string s = Console.ReadLine()!;       // assert input isn't null
Person p = null!;                     // placeholder, will be initialized soon

Overusing 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.

csharp
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.

csharp
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?`

csharp
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**

text
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

csharp
string name = Console.ReadLine()!;     // assert input is not null
public string Name { get; set; } = null!;  // placeholder β€” will be set soon

**Output (with input "Jisoo")**

text
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 `??`/`??=`/`?.`

csharp
string? a = null;
if (a is null) Console.WriteLine("a is null");
string display = a ?? "(no name)";
a ??= "default";
string? upper = b?.ToUpper();

**Output**

text
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)]`

csharp
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**

text
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

csharp
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**

text
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

xml
<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

csharp
// 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

xml
<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

csharp
// 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

xml
<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

csharp
// 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

xml
<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

csharp
// `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

xml
<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

csharp
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

  1. Slathering `!` everywhere to silence warnings β€” you stop catching real null flows.
  2. Forgetting to initialize a `string` (non-nullable) member in the constructor β€” CS8618 warning. Use `required` (lecture 07) or `= ""`.
  3. Using `Equals(null)`/`== null` instead of `is null` β€” same behavior but `is null` is the recommended convention.
  4. 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.
  5. 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

text
name  : 5
email : 0
city  : 5
phone : 0

Hints

  • `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

text
#1 Alice (30 y/o)
#2 Bob (16 y/o)
#99 (not found)
Adults: Alice

Hints

  • 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

xml
<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

csharp
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

xml
<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

csharp
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

bash
cd src/EnableNrt && dotnet run
cd ../NullForgiving && dotnet run
cd ../NullPattern && dotnet run
cd ../NullableAnnotation && dotnet run
cd ../MaybeNullPattern && dotnet run

Next 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.

Example code / lecture materials

All lecture materials and example code are openly available on GitHub.

View on GitHub β†—