Start Debugging

Fix: [MemberNotNull] has no effect on a static factory method and CS8618 still fires

CS8618 is checked when the constructor exits, and [MemberNotNull] on a static method is silently ignored for instance members. Pass the values into a private constructor instead.

[MemberNotNull] on a static factory method cannot clear CS8618, because the warning is reported on the constructor, at the moment the constructor exits, and the compiler never looks at what the factory does afterwards. On top of that, a static method has no this, so when its [MemberNotNull] names an instance member the compiler silently ignores the attribute: no warning on the method, no effect for callers. The fix is to move the assignment back into a constructor: have the factory compute the values and pass them to a private constructor, or mark the members required and set them in an object initializer inside the factory. Verified with the .NET 10 SDK 10.0.302 (C# 14); the behavior is the same since [MemberNotNull] shipped in C# 9.

The error in context

The build output points at the private constructor, not at the factory you annotated:

Widget.cs(8,13): warning CS8618: Non-nullable property 'Name' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

There is no second diagnostic telling you the attribute was ignored. That silence is what makes this confusing: the [MemberNotNull(nameof(Name))] looks like it should be doing something, and nothing in the build says otherwise.

Why this happens

Two separate rules combine here.

CS8618 is checked per constructor, at its exit. The compiler runs nullable flow analysis over each constructor body (plus field and property initializers) and asks one question at the closing brace: is every non-nullable reference member definitely assigned to a non-null value? It does not follow the object after the constructor returns. A factory that does var w = new Widget(); w.Name = name; return w; assigns Name after the constructor has already exited with Name still null, and that exit is where the warning lives. The Roslyn team decided early on not to track initialization across method calls (“We’ve decided not to track initialization interprocedurally for now” in dotnet/roslyn#32358). [MemberNotNull] is the opt-in escape hatch for exactly one case: a helper called from inside the constructor on this.

[MemberNotNull] on a static method only means something for static members. The attribute is a postcondition: “when this method returns, these members of the receiver are not null.” For an instance method the receiver is this (or whatever object you called it on). A static method has no receiver, so the only members it can vouch for are static fields and properties. When you list an instance member, Roslyn accepts the nameof (it compiles fine because nameof(Name) is valid anywhere in the class) and then drops the annotation on the floor. I confirmed both halves of that on SDK 10.0.302: a static method marked [MemberNotNull(nameof(Nick))] that never assigns Nick produces no CS8774, and an instance method that calls it still gets CS8602 when it dereferences Nick afterwards. The same attribute naming a static field is enforced and honored.

So the static factory fails on both counts: it is not the constructor, and its annotation names members it cannot describe.

Minimal repro

// .NET 10 SDK 10.0.302, C# 14, <Nullable>enable</Nullable>
using System.Diagnostics.CodeAnalysis;

public class Widget
{
    public string Name { get; set; }

    private Widget() { }                     // CS8618 is reported here

    [MemberNotNull(nameof(Name))]            // silently ignored: static method, instance member
    public static Widget Create(string name)
    {
        var w = new Widget();
        w.Name = name;
        return w;
    }
}

The second shape of the same mistake is a static helper that takes the instance as a parameter and is called from the constructor. It looks like the documented pattern, but it is not:

// .NET 10 SDK 10.0.302, C# 14
using System.Diagnostics.CodeAnalysis;

public class Gadget
{
    public string Name { get; set; }

    public Gadget(string name) => Init(this, name);   // CS8618 still fires here

    [MemberNotNull(nameof(Name))]
    private static void Init(Gadget g, string name) => g.Name = name;
}

[MemberNotNull] describes the receiver of the call, and Init(this, name) has no receiver. Passing this as an argument does not count. The only static version that works is the one where both the helper and the members are static, for example a static constructor calling a static loader:

// .NET 10 SDK 10.0.302, C# 14
using System.Diagnostics.CodeAnalysis;

public static class Config
{
    public static string ConnectionString;

    static Config() => Load();                 // no CS8618: static helper, static member

    [MemberNotNull(nameof(ConnectionString))]
    private static void Load() => ConnectionString = "Server=.;Database=app";
}

Fix, in detail

All the fixes below put the assignment back where CS8618 can see it: inside a constructor, or behind a contract the compiler understands. They are in order of preference.

1. Pass the computed values into a private constructor

This is the right shape for almost every factory. The factory does the work (validation, parsing, I/O), then hands finished values to a constructor that only assigns:

// .NET 10 SDK 10.0.302, C# 14
public sealed class Widget
{
    public string Name { get; }

    private Widget(string name) => Name = name;

    public static Widget Create(string raw)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(raw);
        return new Widget(raw.Trim());
    }
}

No attribute, no suppression, and Name can become get-only, which a two-step “construct then assign” factory could never offer. If you have several members, the constructor simply grows parameters. A record with a private positional constructor is the compact version of the same idea.

2. Turn the static helper into a function that returns the value

For the Init(this, ...) shape, change the helper so it returns what it computed, and let the constructor assign it:

// .NET 10 SDK 10.0.302, C# 14
public sealed class Gadget
{
    public string Name { get; }

    public Gadget(string raw) => Name = Normalize(raw);

    private static string Normalize(string raw) => raw.Trim().ToLowerInvariant();
}

A static method with a return value is easier to test than one that mutates a parameter, and the compiler sees a plain assignment in the constructor. When the helper produces several values, return a tuple and deconstruct it: (Name, Slug) = Parse(raw); is a definite assignment of both members.

3. Make the helper an instance method and call it from the constructor

If the initialization really is a chunk of shared imperative code, keep it in a helper, but make the helper an instance method so [MemberNotNull] describes this:

// .NET 10 SDK 10.0.302, C# 14
using System.Diagnostics.CodeAnalysis;

public sealed class Report
{
    public string Title { get; private set; }

    private Report(string raw) => Normalize(raw);

    [MemberNotNull(nameof(Title))]
    private void Normalize(string raw) => Title = raw.Trim();

    public static Report Create(string raw) => new(raw);
}

This is the pattern the Microsoft Learn nullable attributes page documents. Unlike the static case, the compiler checks the helper: if Normalize can return without assigning Title, you get CS8774: Member 'Title' must have a non-null value when exiting.

One trap: calling the instance helper from the factory on the new object (var r = new Report(); r.Normalize(raw);) does make r.Title not-null for the rest of the factory, because [MemberNotNull] applies to whatever receiver you call it on. It still does nothing for CS8618, because the constructor already exited before that line ran.

4. Mark the members required and use an object initializer in the factory

If you prefer property-style construction, required moves the obligation from the constructor to the object creation expression, and the factory is the only place that can create one:

// .NET 10 SDK 10.0.302, C# 14
public sealed class Invoice
{
    public required string Number { get; init; }
    public required string Customer { get; init; }

    private Invoice() { }

    public static Invoice Create(string number, string customer) =>
        new() { Number = number.Trim(), Customer = customer.Trim() };
}

CS8618 goes away because a required member is not the constructor’s responsibility. You also get a safety net the original factory never had: forget one of them and the build fails with CS9035: Required member 'Invoice.Customer' must be set in the object initializer or attribute constructor. That is an error, not a warning. The CS9035 walkthrough covers the caller side of this contract.

5. Use [SetsRequiredMembers] on a private constructor

If the type has required members for its public callers but the factory wants to pass values positionally, add a constructor that promises to set them all:

// .NET 10 SDK 10.0.302, C# 14
using System.Diagnostics.CodeAnalysis;

public sealed class Tag
{
    public required string Value { get; init; }

    public Tag() { }

    [SetsRequiredMembers]
    private Tag(string value) => Value = value;

    public static Tag Create(string raw) => new(raw.Trim().ToLowerInvariant());
}

Like [MemberNotNull], [SetsRequiredMembers] is trusted, not verified: if that constructor forgets a member, nothing warns. Keep the constructor short enough that you can check it by eye.

6. Last resort: suppress on the constructor, not the property

If the type is built by a framework that requires a parameterless constructor and fills members afterwards, scope a suppression to that constructor so you do not lose analysis anywhere else:

// .NET 10 SDK 10.0.302, C# 14
public sealed class LegacyRow
{
    public string Name { get; private set; }

#pragma warning disable CS8618 // Name is always assigned by Create before the instance escapes
    private LegacyRow() { }
#pragma warning restore CS8618

    public static LegacyRow Create(string name) => new() { Name = name };
}

This is honest about what is going on, and the comment tells the next reader which method holds up the promise. Prefer it over = null! on the property: null! silences the warning for every constructor the type will ever have, including ones added later that really do forget to assign.

Gotchas and variants

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back