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

📚 Interview question

What are nullable reference types in C#? Explain the annotations (? and !), how the compiler's null-state flow analysis works, attributes such as [NotNullWhen] and [MemberNotNull], and their limits at run time.

Nullable reference types (NRT) in C# 14: nullable contexts, annotations and the null-forgiving operator, flow analysis, nullable attributes, generics, and the gaps where nulls still get through at run time.

Mid-Level Theory Advanced attributes csharp null-safety nullable-reference-types static-analysis types

🎯 Key points

  • NRT (C# 8) is compile-time only: string? and string are the same run-time type; warnings, not run-time checks
  • Enabled by <Nullable>enable</Nullable> (on in new templates since .NET 6) or #nullable enable / disable / restore per file
  • The compiler tracks a null-state (not-null or maybe-null) for each expression through branches, patterns and assignments
  • The ! (null-forgiving) operator only silences the warning; it has no run-time effect
  • Attributes teach the analyser about contracts: NotNullWhen, MaybeNullWhen, NotNull, AllowNull, DisallowNull, MemberNotNull, NotNullIfNotNull, DoesNotReturn
  • required members (C# 11) and constructors satisfy non-nullable property warnings
  • Unconstrained T? means 'may be default'; the notnull constraint forbids nullable type arguments
  • Gaps: default struct fields, new string[n], deserialisation, reflection and unannotated libraries can still produce nulls

⏱ 30-second 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.

🗣 Full answer (senior)

How I would answer this in an interview

Nullable reference types (NRT, C# 8+) are a compile-time static analysis feature, not a new runtime type. With <Nullable>enable</Nullable>, string means "should never be null" and string? means "may be null". The compiler tracks the null-state of every expression through the code and warns when you might dereference null or assign null to a non-nullable location. At run time, nothing changes — string and string? are the same System.String.

1. Enabling it

<PropertyGroup>
  <Nullable>enable</Nullable>
  <WarningsAsErrors>nullable</WarningsAsErrors>  <!-- recommended once clean -->
</PropertyGroup>

New .NET templates enable it by default. For legacy code you can migrate file by file with #nullable enable or use <Nullable>warnings</Nullable> first.

2. The annotations

Syntax Meaning
string name Non-nullable: never assign null, safe to dereference
string? name Nullable: may be null, check before dereferencing
name! Null-forgiving: "trust me, not null" — suppresses the warning, no runtime effect
T? on unconstrained generic Nullable if T is a reference type; unchanged for value types
where T : notnull T must be a non-nullable type

3. How flow analysis works

Each variable has a null-state at every point: not-null or maybe-null.

string? email = customer.Email;          // maybe-null
if (email is null) return;               // after this line: not-null
var domain = email.Split('@')[1];        // no warning

var name = customer.Nickname ?? customer.FullName;  // not-null if FullName is non-nullable

The compiler understands null checks (is null, == null, ?., ??, throw guards, ArgumentNullException.ThrowIfNull) and narrows the state. It also tracks fields: a non-nullable field not initialised in every constructor gives CS8618.

4. Attributes that teach the compiler about your APIs

Flow analysis does not look inside method bodies of callees, so APIs describe their behaviour with attributes from System.Diagnostics.CodeAnalysis:

Attribute Example Effect
[NotNullWhen(true)] bool TryGet(string key, [NotNullWhen(true)] out Customer? c) c is not-null when the method returns true
[MaybeNullWhen(false)] Generic TryGetValue with out T value value may be default/null on false
[NotNull] void EnsureLoaded([NotNull] ref Data? d) Argument is not-null after the call
[MemberNotNull(nameof(_conn))] On an Init() method _conn is not-null after Init() returns (fixes CS8618 for init helpers)
[MemberNotNullWhen(true, nameof(Value))] On bool IsSuccess Value is not-null when IsSuccess is true
[NotNullIfNotNull(nameof(input))] string? Normalise(string? input) Result not-null when input is not-null
[DoesNotReturn] / [DoesNotReturnIf(false)] Throw helpers, Debug.Assert Code after the call is unreachable
[AllowNull] / [DisallowNull] Property setters/getters with asymmetric rules Fine-grained in/out contracts

The BCL is fully annotated, so string.IsNullOrEmpty(s) already narrows s to not-null on false.

5. Limits at run time

NRT gives no runtime guarantee:

  • Callers in projects without NRT, ! operators, reflection, default in generics, and deserialisers can all put null into a non-nullable property.
  • System.Text.Json ignores annotations unless you opt in: .NET 9 added RespectNullableAnnotations and RespectRequiredConstructorParameters options.
  • EF Core does use annotations: non-nullable reference properties become NOT NULL columns and required relationships.
  • Arrays: new string[10] is typed string[] but full of nulls — the compiler cannot track elements.
  • Struct fields of reference type are null in default(MyStruct).

So public API boundaries still need guards: ArgumentNullException.ThrowIfNull(arg) and request validation.

6. A production example

On a large legacy service, NullReferenceExceptions were the top unhandled exception in Application Insights. We enabled NRT project by project, starting with the domain and contracts libraries, using <Nullable>warnings</Nullable> first and fixing ~400 warnings. The most valuable fixes were not the obvious ones: the analysis exposed DTO properties that were genuinely optional but treated as mandatory, and Try methods missing [NotNullWhen(true)], which had trained developers to scatter !. After the migration we made nullable warnings errors in CI and banned ! in code review unless justified with a comment. NREs in production dropped to a handful, mostly from deserialised payloads — which we then fixed by enabling RespectNullableAnnotations and validation.

🔍 What the interviewer is checking

The interviewer checks real-world fluency with a feature that every modern .NET codebase uses.

Green flags

  • States clearly that NRT is compile-time only.
  • Explains flow analysis and uses attributes like [NotNullWhen].
  • Knows required, notnull and unconstrained T?.
  • Lists the run-time gaps and still validates boundaries.

Red flags

  • Uses ! liberally.
  • Thinks string? is a different type at run time, or confuses it with Nullable<T>.
  • Has never enabled or fixed NRT warnings.

📚 Learn in depth

1. How annotations are stored

Because string? is still System.String, the compiler records nullability in metadata attributes:

  • [Nullable(1)] = not-null, [Nullable(2)] = maybe-null, [Nullable(0)] = oblivious. For generic types, a byte array encodes each type argument (Dictionary<string, List<string?>> → [1, 1, 1, 2]).
  • [NullableContext(n)] on a type or method sets the default to shrink metadata.
  • At run time, NullabilityInfoContext (.NET 6+) reads these attributes — ASP.NET Core model binding uses it to infer [Required] for non-nullable properties, which is why enabling NRT can suddenly make API fields required and return 400s.

Code compiled with nullable disabled is "oblivious": its types are treated as neither nullable nor non-nullable, so no warnings appear when consuming it.

2. The two contexts: annotation and warning

<Nullable> has four values:

Value Annotations (? meaningful) Warnings
disable No No
annotations Yes No
warnings No (oblivious) Yes
enable Yes Yes

This split allows staged migration: first annotate public APIs (annotations), or first see warnings on existing code (warnings), then enable.

3. The flow analysis model

The compiler performs a data-flow pass over the method's control-flow graph, similar to definite assignment:

  • Every expression has a null-state. Literals and new are not-null; default and null literals are maybe-null; a read of a declared T? is maybe-null; a read of T is not-null.
  • Conditionals split state: in if (x != null) { A } else { B }, x is not-null in A.
  • After a dereference (x.Length), the compiler assumes not-null for the rest of the path (it warned once at the dereference).
  • Calls invalidate nothing about locals, but fields and properties are trusted to keep their state between calls — the compiler does not assume a method call could null out this.Customer. That is a deliberate unsoundness to avoid warning noise.
  • Lambdas are analysed with the state at the point of declaration for captured variables, which can be wrong if the variable is reassigned later.

The analysis is intentionally unsound in several places (fields, arrays, struct defaults, default in generics) to keep false positives manageable. It finds the vast majority of bugs, not all.

4. Generics and nullability

  • T? on unconstrained T: for reference types adds nullability; for value types, nothing (not Nullable<T>). So T? FirstOrDefault<T>() returns 0 for int.
  • where T : class → T non-nullable reference; where T : class? → any reference type.
  • where T : notnull → non-nullable reference or value type (used by Dictionary<TKey, ...>'s key).
  • [MaybeNull] T / [return: MaybeNull] were the pre-C# 9 way of expressing "may return default".

5. required members and constructors

CS8618 (non-nullable field not initialised) is the most common warning in DTOs. Options, from best to worst:

  1. Constructor or primary constructor parameters (records do this naturally).
  2. required modifier (C# 11): public required string Email { get; init; } — the compiler forces object initialisers to set it, and System.Text.Json (.NET 7+) honours required during deserialisation.
  3. Initialise to a sensible value (= string.Empty;, = [];) when empty is meaningful.
  4. = null!; — only for framework-populated properties, such as EF Core navigation properties.

6. Interaction with EF Core and ASP.NET Core

  • EF Core: string Name → nvarchar(...) NOT NULL; string? Notes → nullable column. Required navigation properties are configured from annotations. Enabling NRT on an existing model can generate migrations that change column nullability — review them carefully.
  • MVC model binding / minimal APIs: non-nullable reference properties are implicitly required (SuppressImplicitRequiredAttributeForNonNullableReferenceTypes turns this off). A missing field becomes a 400 validation error instead of a null.
  • Options pattern: use [Required] plus ValidateDataAnnotations().ValidateOnStart() because configuration binding does not respect NRT.

7. Migration strategy for a large codebase

  1. Enable in new projects and leaf libraries first (no dependants to break).
  2. Annotate public contracts first — every consumer benefits.
  3. Use #nullable enable per file for gradual adoption in big projects.
  4. Fix warnings by understanding intent, not by !. Track warning counts in CI.
  5. Turn nullable warnings into errors once clean, so they never regress.

Try it yourself

  1. Write bool TryFind(int id, out Customer? c) and observe the warning when using c after true; add [NotNullWhen(true)].
  2. Create a class with an Initialize() method that sets a field and fix CS8618 with [MemberNotNull].
  3. Deserialise {} into a record with a non-nullable string Name and observe it is null; then enable RespectNullableAnnotations.
  4. Inspect a compiled assembly in ILSpy and find the NullableAttribute bytes.

💻 Code

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

namespace Types;

public sealed class Customer
{
    public required string Name { get; init; }      // must be set by initialiser
    public string? MiddleName { get; init; }        // optional
    public string Email { get; init; } = string.Empty;

    public string DisplayName =>
        MiddleName is { Length: > 0 } middle ? $"{Name} {middle[0]}." : Name;
}

public sealed class CustomerRepository
{
    private readonly Dictionary<int, Customer> _byId = new();
    private List<Customer> _cache;

    public CustomerRepository() => Reload();

    [MemberNotNull(nameof(_cache))]
    private void Reload() => _cache = [.. _byId.Values];

    public bool TryFind(int id, [NotNullWhen(true)] out Customer? customer)
        => _byId.TryGetValue(id, out customer);

    public Customer? Find(int id) => _byId.GetValueOrDefault(id);

    public void Add(Customer customer)
    {
        ArgumentNullException.ThrowIfNull(customer);   // still validate public inputs
        _byId[_byId.Count + 1] = customer;
        Reload();
    }
}

public static class NrtDemo
{
    public static void Main()
    {
        var repo = new CustomerRepository();
        repo.Add(new Customer { Name = "Ada" });

        if (repo.TryFind(1, out var c))
            Console.WriteLine(c.DisplayName);          // no warning: NotNullWhen(true)

        Customer? maybe = repo.Find(42);
        Console.WriteLine(maybe?.Name ?? "not found");

        // Run-time gap: JSON can still produce null unless you opt in
        var options = new JsonSerializerOptions { RespectNullableAnnotations = true };
        try
        {
            JsonSerializer.Deserialize<Customer>("""{"Name":null}""", options);
        }
        catch (JsonException ex)
        {
            Console.WriteLine($"Rejected: {ex.Message}");
        }
    }
}

⚠ Common mistakes

1. Treating NRT as a runtime guarantee

What happens: public methods skip ArgumentNullException.ThrowIfNull because the parameter is non-nullable. Why it is wrong: callers without NRT, reflection, deserialisers and ! can still pass null; the NRE then happens deep inside your code. Do instead: keep guard clauses at public boundaries and validate deserialised input.

2. Sprinkling ! to silence warnings

What happens: customer!.Address!.City everywhere after enabling NRT. Why it is wrong: ! hides the warning without fixing anything — you have converted compile-time information into a production NRE. Do instead: fix the flow (guards, patterns, ?? throw), annotate APIs with attributes, and require a justification comment for any remaining !.

3. Using = null! as the default fix for CS8618

What happens: every DTO property becomes public string Name { get; set; } = null!;. Why it is wrong: objects can be created without the property set, and the compiler can no longer help. Do instead: use constructors, records, or required members; reserve null! for framework-set properties like EF navigations.

4. Missing attributes on Try-pattern and helper methods

What happens: bool TryGet(int id, out Order? order) without [NotNullWhen(true)], or validation helpers without [NotNull]/[DoesNotReturn]. Why it is wrong: callers get false warnings and learn to ignore or suppress them. Do instead: annotate helpers so the compiler understands their contract.

5. Enabling NRT on an existing API without checking model binding

What happens: after enabling NRT, previously optional request fields become implicitly required and clients start getting 400 responses. Why it is wrong: ASP.NET Core treats non-nullable reference properties as required. Do instead: mark genuinely optional fields as string?, review the OpenAPI diff, and communicate contract changes.

6. Not reviewing EF Core migrations after enabling NRT

What happens: a migration silently changes columns from NULL to NOT NULL (or the reverse). Why it is wrong: the migration can fail on existing null data or loosen constraints unintentionally. Do instead: review generated migrations and mark properties nullable where data really can be missing.

7. Assuming T? in generics means Nullable<T>

What happens: a generic method returns T? and callers with int expect null for "not found". Why it is wrong: for value types, unconstrained T? is just T, so "not found" returns 0. Do instead: use a Try-pattern, a result type, or separate overloads with where T : struct / where T : class.

8. Trusting deserialised objects

What happens: a request DTO with string Name arrives with Name == null because JSON omitted it. Why it is wrong: serialisers populate objects without running your constructors or respecting annotations by default. Do instead: use required, enable RespectNullableAnnotations (.NET 9+), and validate requests.

9. Leaving nullable warnings as warnings forever

What happens: hundreds of warnings accumulate and nobody reads them. Why it is wrong: real issues are hidden in the noise. Do instead: drive the count to zero and set <WarningsAsErrors>nullable</WarningsAsErrors>.

10. Making everything nullable to avoid warnings

What happens: every reference becomes ? so nothing warns. Why it is wrong: callers must now null-check everything, and the type system carries no information. Do instead: decide intentionally: non-nullable by default, ? only where absence is a valid state.

🏛 Senior insight: trade-offs

NRT is a static contract system; its value depends on honest annotations and treating warnings as errors.

Layer Strategy
API boundary (controllers, message handlers) validate inputs; enable JSON RespectNullableAnnotations
Domain model non-nullable by default, required members, no null!
Data access align EF Core nullability with the schema; review migrations for unexpected NULL changes
Shared libraries annotate fully; consumers rely on your metadata
Legacy code enable per project or file, warnings first, then errors
  • Turning on NRT changes EF Core and MVC behaviour (required columns and [Required] validation), so review migrations and API contracts when enabling it on an existing project.
  • The rollout strategy for a large codebase is its own topic (per-project enablement, baselines, warnings-as-errors ratchet).
  • Nullable attributes are part of a library's public API; changing them can break consumers' builds when they treat warnings as errors.

🔗 Follow-ups the interviewer may ask

  1. When: The candidate says they would enable NRT on a legacy project. Why: Moves from language semantics to rolling NRT out across a large existing codebase.
    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.

  2. When: The candidate discusses returning null for not found. Why: Tests applying nullability to API return-type design.
    How to answer

    The return type is the contract, so I would make it honest. Not-found becomes Task<Order?> with nullable reference types on, so callers are forced to handle null, or a Result<Order> if there are several expected failure reasons. The two out parameters must go because async methods cannot have out parameters; their information moves into the result object. The internal List<Order> is replaced with IReadOnlyList<Order> (or a copy) so callers cannot mutate our state. C# gives us void, values, tuples, records, out/Try pattern, ref returns, IEnumerable<T>/IAsyncEnumerable<T>, and Task/ValueTask.

  3. When: The candidate mentions unconstrained T? or Nullable<T>. Why: Checks the difference between T? for structs and for references, especially in generics.
    How to answer

    int? is Nullable<int>, a struct that pairs a value with a HasValue flag, so a value type can represent "no value" (a nullable database column, an optional filter). Reading .Value when it is empty throws, so I use ??, GetValueOrDefault() or if (x is int v). Operators are lifted: null + 1 is null, and comparisons like null > 0 are false. Boxing is special: a null int? boxes to a null reference and a non-null one boxes to a plain boxed int. Unlike string?, int? is a real run-time type.

🛠 Real-world problems: how you handle them

NullReferenceException in customer display names found by enabling NRT
Situation
A customer-portal API (about 300,000 customers) had intermittent 500 errors on profile pages. The domain project had nullable disabled.
Signals
Application Insights showed NullReferenceException in DisplayNameBuilder for roughly 0.5% of profile requests, always for the same customers.
Investigation
Enabled <Nullable>enable</Nullable> on the domain project and reviewed the 120 warnings; CS8602 in DisplayNameBuilder pointed at MiddleName, which the database allowed to be NULL. Confirmed with a SQL query that the failing customers had no middle name.
Resolution
Annotated MiddleName as string?, fixed the builder with a pattern check, fixed nine other real null bugs found by warnings, and annotated the rest honestly.
Prevention
Nullable warnings as errors in that project, RespectNullableAnnotations for inbound JSON, and a test fixture that creates customers with all optional fields empty.

How to tell it (STAR)

**S:** Our customer portal [at company] threw 500 errors for a small set of customers. **T:** I needed to fix it and find similar hidden bugs. **A:** Instead of patching one null check, I enabled nullable reference types on the domain project, triaged the warnings, found the middle-name bug and nine others, and made nullable warnings build errors. **R:** The profile errors went to zero, and [number] potential null bugs were fixed before they reached production.

↩ Usually asked after

More in Types

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me