Sunday, 20 December 2020

Nullable reference types; CSharp's very own strictNullChecks

'Tis the season to play with new compiler settings! I'm a very keen TypeScript user and have been merrily using strictNullChecks since it shipped. I was dimly aware that C# was also getting a similar feature by the name of nullable reference types.

It's only now that I've got round to taking at look at this marvellous feature. I thought I'd share what moving to nullable reference types looked like for me; and what code changes I found myself making as a consequence.

Turning on nullable reference types

To turn on nullable reference types in a C# project you should pop open the .csproj file and ensure it contains a <Nullable>enable</Nullable>. So if you had a .NET Core 3.1 codebase it might look like this:

  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>

When you compile from this point forward, possible null reference types are reported as warnings. Consider this C#:

    [ApiController]
    public class UserController : ControllerBase
    {
        private readonly ILogger<UserController> _logger;

        public UserController(ILogger<UserController> logger)
        {
            _logger = logger;
        }

        [AllowAnonymous]
        [HttpGet("UserName")]
        public string GetUserName()
        {
            if (User.Identity.IsAuthenticated) {
                _logger.LogInformation("{User} is getting their username", User.Identity.Name);
                return User.Identity.Name;
            }

            _logger.LogInformation("The user is not authenticated");
            return null;
        }
    }

A dotnet build results in this:

dotnet build --configuration release

Microsoft (R) Build Engine version 16.7.1+52cd83677 for .NET
Copyright (C) Microsoft Corporation. All rights reserved.

  Determining projects to restore...
  Restored /Users/jreilly/code/app/src/server-app/Server/app.csproj (in 471 ms).
Controllers/UserController.cs(38,24): warning CS8603: Possible null reference return. [/Users/jreilly/code/app/src/server-app/Server/app.csproj]
Controllers/UserController.cs(42,20): warning CS8603: Possible null reference return. [/Users/jreilly/code/app/src/server-app/Server/app.csproj]
  app -> /Users/jreilly/code/app/src/server-app/Server/bin/release/netcoreapp3.1/app.dll
  app -> /Users/jreilly/code/app/src/server-app/Server/bin/release/netcoreapp3.1/app.Views.dll

Build succeeded.

Controllers/UserController.cs(38,24): warning CS8603: Possible null reference return. [/Users/jreilly/code/app/src/server-app/Server/app.csproj]
Controllers/UserController.cs(42,20): warning CS8603: Possible null reference return. [/Users/jreilly/code/app/src/server-app/Server/app.csproj]
    2 Warning(s)
    0 Error(s)

You see the two "Possible null reference return." warnings? Bingo

Really make it hurt

This is good - information is being surfaced up. But it's a warning. I could ignore it. I like compilers to get really up in my face and force me to make a change. I'm not into warnings; I'm into errors. Know what works for you. If you're similarly minded, you can upgrade nullable reference warnings to errors by tweaking the .csproj a touch further. Add yourself a <WarningsAsErrors>nullable</WarningsAsErrors> element. So maybe your .csproj now looks like this:

  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
    <Nullable>enable</Nullable>
    <WarningsAsErrors>nullable</WarningsAsErrors>
  </PropertyGroup>

And a dotnet build will result in this:

dotnet build --configuration release

Microsoft (R) Build Engine version 16.7.1+52cd83677 for .NET
Copyright (C) Microsoft Corporation. All rights reserved.

  Determining projects to restore...
  Restored /Users/jreilly/code/app/src/server-app/Server/app.csproj (in 405 ms).
Controllers/UserController.cs(38,24): error CS8603: Possible null reference return. [/Users/jreilly/code/app/src/server-app/Server/app.csproj]
Controllers/UserController.cs(42,20): error CS8603: Possible null reference return. [/Users/jreilly/code/app/src/server-app/Server/app.csproj]

Build FAILED.

Controllers/UserController.cs(38,24): error CS8603: Possible null reference return. [/Users/jreilly/code/app/src/server-app/Server/app.csproj]
Controllers/UserController.cs(42,20): error CS8603: Possible null reference return. [/Users/jreilly/code/app/src/server-app/Server/app.csproj]
    0 Warning(s)
    2 Error(s)

Yay! Errors!

What do they mean?

"Possible null reference return" isn't the clearest of errors. What does that actually amount to? Well, it amounts to the compiler saying "you're a liar! (maybe)". Let's look again at the code where this error is reported:

        [AllowAnonymous]
        [HttpGet("UserName")]
        public string GetUserName()
        {
            if (User.Identity.IsAuthenticated) {
                _logger.LogInformation("{User} is getting their username", User.Identity.Name);
                return User.Identity.Name;
            }

            _logger.LogInformation("The user is not authenticated");
            return null;
        }

We're getting that error reported where we're returning null and where we're returning User.Identity.Name which may be null. And we're getting that because as far as the compiler is concerned string has changed. Before we turned on nullable reference types the compiler considered string to mean string OR null. Now, string means string.

This is the same sort of behaviour as TypeScripts strictNullChecks. With TypeScript, before you turn on strictNullChecks, as far as the compiler is concerned, string means string OR null OR undefined (JavaScript didn't feel one null-ish value was enough and so has two - don't ask). Once strictNullChecks is on, string means string.

It's a lot clearer. And that's why the compiler is getting antsy. The method signature is string, but it can see null potentially being returned. It doesn't like it. By and large that's good. We want the compiler to notice this as that's the entire point. We want to catch accidental nulls before they hit a user. This is great! However, what do you do if have a method (as we do) that legitimately returns a string or null?

Widening the type to include null

We change the signature from this:

        public string GetUserName()

To this:

        public string? GetUserName()

That's right, the simple addition of ? marks a reference type (like a string) as potentially being null. Adding that means that we're potentially returning null, but we're sure about it; there's intention here - it's not accidental. Wonderful!