📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

Review this code from a project that has just enabled nullable reference types: it is full of `!` operators, `= default!` on properties, and helper methods like TryGetCustomer that the compiler does not understand. What is wrong, and how do you make nullable annotations actually protect you? Cover the nullable attributes, generics with T?, EF Core and deserialisation.

Advanced nullable reference types: code review of common misuse, nullable attributes, generics and framework boundaries.

Mid-Level CodeReview Advanced code-review csharp ef-core nullable-reference-types static-analysis

🎯 Key points

  • Nullable reference types are compile-time flow analysis with no run-time enforcement
  • The ! operator and = default! hide nulls and should be rare and justified
  • Use required members or constructor parameters for values that must exist
  • Nullable attributes such as NotNullWhen, MemberNotNull, NotNullIfNotNull and DoesNotReturn teach the compiler helper contracts
  • T? on an unconstrained generic means maybe default, not Nullable<T>
  • Validate at boundaries: JSON, EF Core, reflection and unannotated libraries can still produce null
  • With NRT on, EF Core maps non-nullable reference properties to NOT NULL columns
  • Roll out per file, public APIs first, then make nullable warnings errors

⏱ 30-second answer

Nullable reference types are compile-time flow analysis, not a run-time guarantee, so every ! and = default! is a promise the compiler cannot check. In review I remove ! that hides real nulls, replace = default! with required members or constructor parameters, and teach the compiler my helpers' contracts with attributes: [NotNullWhen(true)] on Try methods, [MemberNotNull] on initialisers, [NotNullIfNotNull], [DoesNotReturn] on throw helpers. For generics, T? on an unconstrained T means "may be default". At boundaries the compiler cannot see (JSON, EF Core queries, reflection, old libraries) I validate at run time, because a non-nullable string can still hold null there. With NRT on, EF Core also maps non-nullable reference properties to NOT NULL columns, so annotations change your schema.

🗣 Full answer (senior)

Code under review (representative)

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = default!;          // silence CS8618
    public string Email { get; set; } = default!;
    public Address Address { get; set; } = default!;
    public string? Phone { get; set; }
}

public bool TryGetCustomer(int id, out Customer? customer) { ... }

if (repo.TryGetCustomer(id, out var c))
    SendEmail(c!.Email);                                   // ! because the compiler warns

var dto = JsonSerializer.Deserialize<CustomerDto>(json)!;
var city = dto.Address!.City!.ToUpper();
T? First<T>(IEnumerable<T> items) => items.FirstOrDefault();

What's wrong

The project enabled nullable reference types but then switched them off line by line:

  1. = default! on properties tells the compiler "this is never null" while initialising it to null. Any new Customer() or deserialised object can have Name == null and the compiler won't warn — the exact bug NRT was meant to catch.
  2. ! sprinkled at call sites (c!.Email, dto.Address!.City!) suppresses warnings without proving anything. Each ! is a potential NullReferenceException the compiler would have found.
  3. Helpers the compiler doesn't understand: TryGetCustomer(out Customer? customer) returns true with a non-null customer, but without annotations the compiler assumes customer may still be null after true — so developers add !.
  4. Deserialize(...)! — deserialisation can return null (for JSON null) and can leave non-nullable properties null if fields are missing; ! hides both.
  5. Generic T? in First<T> doesn't mean Nullable<T> for value types: for int, T? is just int, returning 0 for "not found".
  6. EF Core mapping implications: string Name (non-nullable) maps to a NOT NULL column, string? Phone to nullable — so annotations aren't cosmetic; wrong ones change the schema or hide data problems.

How to make annotations protect you

Initialise honestly. Use constructors, required members, or real defaults instead of default!:

public sealed class Customer
{
    private Customer() { }                                 // for EF Core materialisation
    public Customer(string name, EmailAddress email, Address address) { Name = name; Email = email; Address = address; }

    public int Id { get; private set; }
    public string Name { get; private set; } = null!;     // EF sets it; acceptable, documented exception
    public EmailAddress Email { get; private set; } = null!;
    public Address Address { get; private set; } = null!;
    public string? Phone { get; private set; }
}

public sealed record CustomerDto
{
    public required string Name { get; init; }
    public required string Email { get; init; }
    public AddressDto? Address { get; init; }              // honest: may be missing
}

null! remains only where a framework (EF Core) guarantees initialisation, ideally behind private setters/constructors — not on public DTOs.

Teach the compiler with attributes:

public bool TryGetCustomer(int id, [NotNullWhen(true)] out Customer? customer) { ... }
if (repo.TryGetCustomer(id, out var c)) SendEmail(c.Email);   // no ! needed

[MemberNotNull(nameof(_connection))] private void EnsureConnected() { _connection ??= Open(); }
public static string? Normalise([NotNullIfNotNull(nameof(input))] string? input) => input?.Trim();
[DoesNotReturn] static void Fail(string message) => throw new InvalidOperationException(message);

Handle deserialisation properly:

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { RespectNullableAnnotations = true, RespectRequiredConstructorParameters = true };  // .NET 9
var dto = JsonSerializer.Deserialize<CustomerDto>(json, options) ?? throw new BadRequestException("Body required");

Plus validation (required, data annotations/FluentValidation), and mapping Address to a domain object only after checking it.

Generics: be explicit about intent:

static T? FirstOrNull<T>(IEnumerable<T> items) where T : class => items.FirstOrDefault();
static T? FirstOrNull<T>(IEnumerable<T> items, int _ = 0) where T : struct => items.Cast<T?>().FirstOrDefault();
// or a Try pattern: bool TryFirst<T>(IEnumerable<T> items, [MaybeNullWhen(false)] out T value)

Process guardrails:

  • <Nullable>enable</Nullable> + <WarningsAsErrors>nullable</WarningsAsErrors>.
  • Review rule: every ! needs a justification comment; prefer fixing the flow (guard clauses, ?? throw, patterns).
  • Keep ArgumentNullException.ThrowIfNull at public boundaries — annotations are compile-time only.
  • Review EF Core migrations after changing annotations.

A production example

After a team "enabled NRT" by adding = default! and ! everywhere, NullReferenceExceptions in Application Insights didn't drop at all. Reviewing the top NREs showed most were in exactly those suppressed places (deserialised DTOs with missing fields, Try methods). Adding [NotNullWhen(true)], required on DTOs, RespectNullableAnnotations, and removing ~300 ! operators (with real fixes) cut NREs by over 80% within two releases.

🔍 What the interviewer is checking

The interviewer wants to see whether you use NRT as a real safety tool or just silence it.

Green flags

  • Calls ! and = default! smells and explains the run-time reality.
  • Knows [NotNullWhen], [MemberNotNull], [DoesNotReturn] and when to use them.
  • Explains T? on unconstrained generics.
  • Validates at boundaries and knows the EF Core schema impact.

Red flags

  • Thinks NRT prevents nulls at run time.
  • Suggests disabling nullable warnings in hard files permanently.
  • Unaware that required exists or what it changes.

📚 Learn in depth

1. What ! and default! do

The null-forgiving operator changes only the compiler's null-state for that expression to "not-null". It emits no IL and performs no check. default! on a non-nullable reference property initialises it to null while telling the compiler it's not null — the worst of both worlds.

2. Attribute catalogue

Attribute Applies to Meaning
[NotNullWhen(bool)] out/ref params Not null when the method returns the given bool
[MaybeNullWhen(bool)] out params (generic) May be null/default when returning the given bool
[NotNull] params/returns/fields Not null after the call (even if declared nullable)
[MaybeNull] returns/fields (generic) May be null/default
[AllowNull] / [DisallowNull] setters/params Input contracts differing from the declared type
[NotNullIfNotNull(param)] returns Not null if the named parameter isn't null
[MemberNotNull(names)] / [MemberNotNullWhen(bool, names)] methods/properties Named members are not null after the call / when returning bool
[DoesNotReturn] / [DoesNotReturnIf(bool)] methods/params Code after the call is unreachable (throw helpers, asserts)

3. Generics and T?

  • Unconstrained T?: nullable for reference types, unchanged for value types.
  • where T : class → T? is a nullable reference.
  • where T : struct → T? is Nullable<T>.
  • where T : notnull → T can't be a nullable type argument.
  • [MaybeNull] T (pre-C# 9 style) or T? returns for "default if not found" APIs.

4. EF Core and NRT

  • Non-nullable reference properties → required (NOT NULL) columns and required relationships.
  • Navigation properties: required references can be null! initialised (EF sets them when loaded) but may be null if not included — a genuine gap; consider not exposing required navigations as non-nullable if they're often not loaded, or always projecting.
  • EF Core docs recommend constructor binding or required/null! patterns with care.

5. Serialisation

  • Default System.Text.Json ignores nullability: missing properties remain default (null).
  • .NET 9 RespectNullableAnnotations throws when null is read/written for non-nullable properties; RespectRequiredConstructorParameters enforces constructor parameters.
  • required keyword members are enforced by STJ (.NET 7+).

6. Measuring progress

  • Count ! occurrences and #nullable disable regions in CI.
  • Track NREs in Application Insights by location.
  • Enable <Nullable>enable</Nullable> per project, starting with leaf/domain projects.

Try it yourself

  1. Remove [NotNullWhen(true)] from a Try method and observe warnings at call sites; add it back.
  2. Deserialise {} into a DTO with required vs default! properties.
  3. Enable RespectNullableAnnotations and deserialise {"name": null}.
  4. Write FirstOrNull<T> with unconstrained T? and call it with int.

💻 Code

using System.Diagnostics.CodeAnalysis;
using System.Text.Json;

#nullable enable

// BEFORE (as found in review)
public sealed class CustomerDtoBefore
{
    public string Reference { get; set; } = default!;  // lies: starts as null
    public string? Notes { get; set; }
}

// AFTER
public sealed class CustomerDto
{
    public required string Reference { get; init; }    // must be supplied; STJ enforces it
    public string? Notes { get; init; }                 // null is a valid state
}

public sealed class CustomerRepository
{
    private readonly Dictionary<int, CustomerDto> _store = new();
    private List<int> _recent;

    public CustomerRepository() => Reset();

    [MemberNotNull(nameof(_recent))]                    // compiler knows _recent is set
    private void Reset() => _recent = [];

    public bool TryGet(int id, [NotNullWhen(true)] out CustomerDto? customer)
        => _store.TryGetValue(id, out customer);

    public void Add(int id, CustomerDto customer)
    {
        ArgumentNullException.ThrowIfNull(customer);    // still validate public inputs
        _store[id] = customer;
        _recent.Add(id);
    }

    [return: NotNullIfNotNull(nameof(fallback))]
    public string? NotesOr(int id, string? fallback)
        => TryGet(id, out var c) ? c.Notes ?? fallback : fallback;
}

public static class Throw
{
    [DoesNotReturn]
    public static void NotFound(int id) => throw new KeyNotFoundException($"Customer {id} not found");
}

public static class Program
{
    static string RequireReference(CustomerRepository repo, int id)
    {
        if (!repo.TryGet(id, out var customer))
            Throw.NotFound(id);                         // [DoesNotReturn]: flow analysis stops here
        return customer.Reference;                      // no warning, no '!'
    }

    public static void Main()
    {
        var repo = new CustomerRepository();
        repo.Add(1, new CustomerDto { Reference = "C-001" });

        if (repo.TryGet(1, out var customer))
            Console.WriteLine(customer.Reference.Length);   // NotNullWhen(true): no warning

        string notes = repo.NotesOr(1, "none");             // non-null because fallback is non-null
        Console.WriteLine(notes);
        Console.WriteLine(RequireReference(repo, 1));

        try
        {
            JsonSerializer.Deserialize<CustomerDto>("{}");
        }
        catch (JsonException ex)
        {
            Console.WriteLine(ex.Message);                  // missing required property 'Reference'
        }
    }
}

⚠ Common mistakes

1. = default! on DTO and entity properties

What happens: used to silence CS8618 everywhere. Why it is wrong: objects can exist with null values the compiler believes are impossible. Do instead: constructors, required, real defaults; null! only for framework-initialised members.

2. ! as a habit

What happens: null-forgiving operators added wherever warnings appear. Why it is wrong: converts compile-time warnings into runtime NREs. Do instead: fix the flow; annotate helpers; justify remaining !.

3. Unannotated Try methods

What happens: bool TryX(out T? value) without [NotNullWhen(true)]. Why it is wrong: false warnings that train developers to use !. Do instead: add the attribute.

4. Trusting deserialisation

What happens: Deserialize<T>(json)! and assuming properties are non-null. Why it is wrong: JSON can be null or missing fields. Do instead: check results, use required, RespectNullableAnnotations and validation.

5. Misusing T? in generics

What happens: expecting null for value types from unconstrained T?. Why it is wrong: returns default (e.g. 0). Do instead: constrained overloads or Try patterns.

6. Ignoring EF Core schema impact

What happens: changing annotations without reviewing migrations. Why it is wrong: columns change nullability; migrations fail on existing data. Do instead: review migrations; align annotations with real data.

7. Dropping runtime guards

What happens: removing ThrowIfNull because parameters are non-nullable. Why it is wrong: external callers can still pass null. Do instead: keep guards at public boundaries.

8. Leaving nullable warnings as warnings

What happens: hundreds of nullable warnings ignored. Why it is wrong: real issues hidden. Do instead: treat nullable warnings as errors once clean.

🏛 Senior insight: trade-offs

Nullable reference types are a documentation system the compiler can check. Their value depends on honesty: one ! in a widely used API spreads false confidence to every caller.

Situation Senior choice
Value must exist from creation constructor parameter or required
Value legitimately absent T? and handle it
Helper method decides null state nullable attributes, not !
Data from outside the process run-time validation at the edge
Large legacy codebase per-file #nullable enable, public APIs first, warnings as errors when clean

I also point out that nullable warnings are cheap bug reports: every one fixed before production is a NullReferenceException that never pages anyone. Track the warning count in CI and only let it go down.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate explains string versus string?. Why: Checks the nullable reference type basics.
    How to answer

    Nullable reference types make nullability part of the declared type for static analysis: string means "should never be null" and string? means "may be null". The compiler tracks a null-state for every expression and warns when a maybe-null value is dereferenced or assigned to a non-nullable. It is compile-time only: there are no run-time checks, and ! merely silences a warning. Attributes like [NotNullWhen(true)] on TryGet methods or [MemberNotNull] on initialisers tell the analyser about contracts it cannot infer. Nulls can still arrive from deserialisation, arrays, default and unannotated code, so I still validate at the boundaries.

  2. When: Candidate mentions enabling NRT in a large codebase. Why: Probes how to roll NRT out across a solution.
    How to answer

    <Nullable>enable</Nullable> makes reference types non-nullable by default: string? means a value can be null and string means it can't. The compiler then warns when code might dereference null. It's purely compile-time, so nothing changes at run time. In a big codebase I migrate bottom-up: shared libraries and domain types first, because their annotations flow out to everyone else. I go project by project, or file by file with #nullable enable, and give each step a small pull request so feature work carries on. When a project is clean I make its nullable warnings errors with WarningsAsErrors=nullable, so it can't slip back. I use attributes such as [NotNullWhen] where flow analysis needs help and keep ! for real boundaries. I also watch for two things: EF Core migrations, where nullability decides whether columns are required, and public APIs, where annotations are part of the contract.

  3. When: Candidate suggests required members. Why: required is the main replacement for = default!.
    How to answer

    An init accessor (C# 9) lets a property be set only during object creation: in the constructor, an object initialiser or a with expression, and never afterwards. A required member (C# 11) forces every caller to set it in the object initialiser, checked by the compiler at each new. Together they give immutable objects with named, mandatory initialisation and no long constructors. A constructor marked [SetsRequiredMembers] tells the compiler it sets everything, so callers using it skip the check. System.Text.Json (.NET 7+) honours required and throws if the property is missing. Choose a constructor instead when you need validation across properties, because object initialisers run after the constructor finishes.

🛠 Real-world problems: how you handle them

Silenced nullable warnings hid a partner JSON null
Situation
An order API accepted orders from [three] partners. During the NRT migration the team added [400] `!` and `= default!` to reach zero warnings in a sprint.
Signals
A NullReferenceException in the billing job weeks later, only for orders from [Partner B]; no validation error at the API edge.
Investigation
Traced the order id back to the raw request in logs: the payload omitted `customerReference`, which was declared `public string CustomerReference { get; set; } = default!;`.
Resolution
Made required DTO properties `required`, enabled `RespectNullableAnnotations` on the API's JSON options, and fixed the remaining `!` sites one by one with attributes or real checks.
Prevention
Added a review rule that every `!` needs a comment, a CI metric for the count of `!` and nullable warnings, and contract tests with partner sample payloads.

How to tell it (STAR)

**Situation:** after we enabled nullable reference types in our [order API], the team cleared [400] warnings mainly with `!`. **Task:** when billing started failing for [Partner B] I was asked to find the cause and stop it recurring. **Action:** I traced the failing order to a payload missing a field we had declared non-nullable with `default!`. I replaced those properties with `required`, turned on nullable-aware JSON validation, and led a clean-up that replaced `!` with nullable attributes or proper checks. **Result:** bad payloads were rejected with a clear 400 at the edge, billing errors stopped, and the `!` count dropped from [400] to [12], each one documented.

↩ Usually asked after

More in Modern C#

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me