C# (.NET 8 LTS) track

Lesson 20 · Part 5: Modern C#

Nullable Reference Types

Turn on nullable reference types and handle nulls with ?., ?? and the null-forgiving operator.

Part 5: Modern C#4 min readFree lesson

Learning Objectives

  • Know the difference between string and string?
  • Understand the effects of #nullable enable / <Nullable>enable</Nullable>
  • Handle null safely with ?., ??, ??=
  • Know when to use ! (null-forgiving) and when to avoid it
  • See the big picture of attributes such as [NotNullWhen(true)]

Key concepts

This one line is the key to turning on NRT. #nullable enable / #nullable disable can be used in file units.

<PropertyGroup>
  <Nullable>enable</Nullable>
</PropertyGroup>

When on:

  • string x = null; → Warning (CS8600)
  • string? x = null; → Normal
  • string? x = ...; x.Length; → Warning (CS8602)

2) string vs string?

notation meaning null assignment Warning when accessing members
string non-nullable warning None
string? nullable OK Warning if null is not checked

The purpose is to make it possible to immediately know "Can this variable be null?" by looking at the type information.

We've already seen it briefly in Unit 03, but we'll summarize it here.

string? name = null;

string display = name ?? "(none)";    //If null, default value
name ??= "Alice";                     //Assignment only if null
int? length = name?.Length;           //If null, null
char? first = name?[0];               //Indexing is also possible

This is an expression that says, “I take responsibility and guarantee it, so the compiler turns off warnings.”

string s = Console.ReadLine()!;       //Asserts that input is not null.
Person p = null!;                     //Placeholder indicating that it will be initialized soon.

When overused, NRT loses its meaning. Only when you are really sure, and to the smallest extent possible.

5) is null vs == null

Recommended is is null. The reason is that == can involve user-defined overloading, but is is a language-level check, so it always works the same.

if (x is null) ...
if (x is not null) ...

The compiler's flow analysis is enhanced with attributes such as [NotNullWhen(true)]. The same principle applies to Dictionary.TryGetValue reporting whether the out parameter is null.

bool TryParseName(string raw, [NotNullWhen(true)] out string? value);
//returns true → informs the compiler that value is not null.

See as example

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}");
}

Execution result

non = hello
null = (none)

Note: One line from .csproj to <Nullable>enable</Nullable> makes all the difference. On by default for new .NET 8 projects.

string name = Console.ReadLine()!;     //input is not null.
public string Name { get; set; } = null!;  //will be filled soon, so turn off the warning.

Execution result (example input "index")

Enter your name: Jisoo
Hello, Jisoo!
Name = Alice

Note: null! often appears in fields that will soon be populated by ORM entities or dependency injection containers. If used excessively in general business code, the meaning of NRT is neutralized.

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

Execution result

a is null
b = hello
display = (no name)
a = default
upper = HELLO
first = h

%EB%A9%94%EB%AA%A8:+%EC%9D%B4+%EB%84%A4+%EA%B0%80%EC%A7%80+%EC %97%B0%EC%82%B0%EC%9E%90(is null,+??,+??=,+?. )%EB%A7%8C+%EC%9E%98+%EC%8D%A8%EB%8F%84+NullReferenceException+%EC%9D%98+95%+%EA%B0%80+%EC%82%AC%EB%9D%BC%EC%A7%91%EB%8B%88%EB%8B%A4.

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

Execution result

Name Length: 5
Failure: empty == null ? True

Note: Thanks to this attribute, the caller does not need to use name! within if. It greatly increases API accuracy when writing libraries.

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");

Execution result

Alice Age: 30
Zoe Age:
Zoe Age (default -1): -1
Alice is an adult

Note: This pattern clearly indicates “there may be no result” as a type. The calling side is neat if handled by matching ?./??part/Pattern.

Common Mistakes

  1. Excessive use of ! to kill warnings — Even if nulls flow in, you cannot catch them.
  2. string (non-nullable) member not initialized in constructor — CS8618 warning. Solved with required (Part 07) or = "".
  3. Use Equals(null)/== null instead of is null — Same behavior, but is null is recommended for consistency.
  4. Assign the value received from an old library with NRT turned off to a non-nullable variable — it may be null at runtime. Receive and inspect with ?.
  5. Use the out parameter of Dictionary.TryGetValue without null checking — the compiler already knows, so it is safe within the if (dict.TryGetValue(...)) branch.

Cleanup

  • NRT is a function that expresses “nullability of reference types” at the type level.
  • Turn on <Nullable>enable</Nullable> or #nullable enable
  • Do not include null in reference types without ? (compiler prevents this)
  • Elegantly handles null with four operators: ?., ??, ??=, is null
  • ! is a last resort — only when you can really take responsibility.

Try it yourself

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

Example files

The runnable files used in this lesson. Copy them into your own project and experiment.

src/EnableNrt/EnableNrt.csprojText
<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.csC#
//NRT is turned on thanks to <Nullable>enable</Nullable> in .csproj.
//or at the top of the file

string nonNullable = "hello";
string? nullable = null;

//Compiler warning: No null assignment to nonNullable.
// nonNullable = null;   // CS8600

//? Variables tagged with must be checked to see if they are null 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.csprojText
<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.csC#
//“Search method” may not have results → Return type as Person? Specified as .
//The calling party is ?. / ?? Handled safely.

List<Person> people =
[
    new("Alice", 30),
    new("Bob", 25),
];

Person? found = FindByName(people, "Alice");
Person? missing = FindByName(people, "Zoe");

//Safe member access — if null, completely null.
Console.WriteLine($"Alice Age: {found?.Age}");          // 30
Console.WriteLine($"Zoe Age: {missing?.Age}");        //(empty value)

//Fill in default value.
int age = missing?.Age ?? -1;
Console.WriteLine($"Zoe Age (default -1): {age}");

//Branch to 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/NullableAnnotation/NullableAnnotation.csprojText
<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.csC#
using System.Diagnostics.CodeAnalysis;

//[NotNullWhen(true)]: Attribute that tells the
//compiler that “the out parameter is not null when this method returns true.” The analyzer sees this and tracks the flow smarter.

if (TryParseName("Alice", out string? name))
{
    //Here, name is inferred as not null → Use Length without warning OK.
    Console.WriteLine($"Name length: {name.Length}");
}

if (!TryParseName("", out string? empty))
{
    Console.WriteLine($"Failure: empty == null ? {empty is null}");
}

//Similar sibling attributes: [NotNull], [MaybeNull], [MemberNotNull], etc. — Useful when writing libraries.
static bool TryParseName(string raw, [NotNullWhen(true)] out string? value)
{
    if (string.IsNullOrWhiteSpace(raw))
    {
        value = null;
        return false;
    }
    value = raw.Trim();
    return true;
}
src/NullForgiving/NullForgiving.csprojText
<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.csC#
//null-forgiving operator ! : A sign that says “I guarantee that the compiler will turn off null warnings.”

//The return of Console.ReadLine() is string? — Can be null at EOF.
//If you are sure that null does not appear in the console input, use ! You can turn it off with .
Console.Write("Please enter your name:");
string name = Console.ReadLine()!;     //! ignore null possibility
Console.WriteLine($"Hello, {name}!");

//Other common uses: Lazy initialization (e.g. SetUp in unit tests)
Person p = GetUser();
Console.WriteLine($"Name = {p.Name}");

//Keep the local function → type declaration order after top-level statements.
static Person GetUser()
{
    return new Person { Name = "Alice" };
}

internal sealed class Person
{
    //It will be filled immediately after creation, but the compiler doesn't know that → null! Indicate initialization intent with .
    public string Name { get; set; } = null!;
}
src/NullPattern/NullPattern.csprojText
<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.csC#
//`is null` vs `== null` — Recommended is `is null`.
//Reason: == can be intruded by user-defined overloading, but is is a language-level check, so it always behaves the same.

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}");

//Associative operator
string display = a ?? "(no name)";       //Substitute value if null
Console.WriteLine($"display = {display}");

a ??= "default";                            //Assign only when null
Console.WriteLine($"a = {a}");

//Even in member access ?.  Chainable
string? upper = b?.ToUpper();
Console.WriteLine($"upper = {upper}");

//Safe Indexing
char? first = b?[0];
Console.WriteLine($"first = {first}");

Assignment

  • Reference solution: shown below
  • Core concepts: Dictionary<string, string?>, ?., ??

Requirements

  • Create Dictionary<string, string?> as follows.
    Dictionary<string, string?> profile = new()
    {
        ["name"] = "Alice",
        ["email"] = null,
        ["city"] = "Seoul",
    };
    
  • Iterates through the key list ["name", "email", "city", "phone"] and outputs the length of each key.
  • If there is no key or the value is null, the length is displayed as 0.
  • Calculate in one line using only the safety operators ?. and ??.

Expected output

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

Hint

  • Extract value as dict.TryGetValue(key, out string? value).

  • Length = value?.Length ?? 0.

  • The output will be neat if sorted by key.PadRight(6), etc.

  • Reference solution: shown below

  • Key concepts: T? return, is { ... } pattern, ??

Requirements

  • Define record User(int Id, string Name, int Age);.
  • Create the following method in the UserRepo class.
  • User? FindById(int id) — If not present, null is returned.
  • Search for IDs 1, 2, and 99 in the main and output the following.
  • If you find it, it will be in the form of ".
  • If you can't find it, it's in the form of ".
  • Additionally, select only adults (Age >= 18) among the found users and print one line in the form of “Adult members: Alice, Bob.” (is { Age: >= 18 } pattern recommended)

Expected output



Adult Member: Alice

Hint

  • Data example:
    new User(1, "Alice", 30),
    new User(2, "Bob", 16),
    
  • At the point of use, User? u = repo.FindById(id); → if (u is null) ... else ....
  • When collecting only adults, List<User> is cleaner than List<User?>.

Check the correct answer

After solving it yourself, compare it with the reference solution below.

Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

Homework01/Homework01.csprojText
<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>
Homework01/Program.csC#
Dictionary<string, string?> profile = new()
{
    ["name"] = "Alice",
    ["email"] = null,
    ["city"] = "Seoul",
};

string[] keys = ["name", "email", "city", "phone"];

foreach (string key in keys)
{
    //If the key does not exist, the value can be null. Even if the key exists, the value can be null.
    profile.TryGetValue(key, out string? value);
    int length = value?.Length ?? 0;   //Handles two null cases at once.
    Console.WriteLine($"{key.PadRight(6)}: {length}");
}
Homework02/Homework02.csprojText
<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>
Homework02/Program.csC#
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($"");
    }
    else
    {
        Console.WriteLine($"");
    }

    //Only adults are selected and collected through pattern matching.
    if (u is { Age: >= 18 })
    {
        adults.Add(u);   //Within the branch, u is inferred to be not null.
    }
}

Console.WriteLine("Adult members:" + 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;
    }
}

Learn this lesson with a mentor

Work through the C# (.NET 8 LTS) track on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now