Skip to main content
Version: Next

Analyzers and Code Fixes

The Masterly.NonEmptyList package ships Roslyn analyzers, code fixes and the [NonEmptyCollection] source generator. They are active in every project that references the package. There is nothing else to install. The analyzers catch what the type system cannot: an empty literal that always throws, a LINQ call that hides a guarantee the type already gives you, and, if you opt in, a throwing LINQ call on a sequence nobody has proven non-empty.

// orders is an IEnumerable<Order> from your application
NonEmptyList<int> xs = []; // NEL001 error: always throws
var first = xs.First(); // NEL003: use xs.Head
if (xs.Count == 0) { } // NEL004: always false
var top = orders.Max(o => o.Total); // NEL005 (opt in): 'orders' is not proven non-empty

Rules at a glance​

RuleCatchesDefault severityOn by defaultCode fix
NEL001[] assigned to a non-empty typeErroryesno
NEL002a non-empty factory called with a provably empty argumentErroryesno
NEL003a throwing LINQ operator where a total member existsInfoyesyes
NEL004an emptiness check the type already decidesInfoyesyes, except for DefaultIfEmpty
NEL005a throwing LINQ operator on a sequence not proven non-emptyWarningnoyes
CS1503, CS0029, CS0266, CS1662v3 migration: a non-empty list used where a List<T> is requiredcompiler erroryes

All rules are in the category Masterly.NonEmptyList. Every fix supports Fix all in document, project or solution. The generator has its own diagnostics, NEL101 to NEL104, listed at the end.

Configuring severity​

Change any rule's severity in .editorconfig:

.editorconfig
[*.cs]
# Make the total-member hint a warning
dotnet_diagnostic.NEL003.severity = warning

# Turn the redundant-check hint off
dotnet_diagnostic.NEL004.severity = none

# Opt in to the unproven-sequence rule
dotnet_diagnostic.NEL005.severity = warning

The values are the standard ones: error, warning, suggestion, silent and none. To silence one occurrence, use #pragma warning disable NEL003 and #pragma warning restore NEL003 around it.

Enabling NEL005. NEL005 is disabled by default. Setting a severity for it in .editorconfig is what turns it on; warning is the intended level. Use error to fail the build once the count reaches zero. Without that line, the rule never runs.

Empty collection expression assigned to a non-empty type​

NEL001, error. NonEmptyList<int> xs = []; compiles, because the collection builder accepts any span, but it always throws ArgumentException at run time. The analyzer reports it at compile time instead.

It applies wherever the collection expression's target type is one of the library's non-empty types, or derives from one: NonEmptyList<T>, ImmutableNonEmptyList<T>, ConstrainedList<T, TConstraint>, NonEmptySet<T>, and the classes generated by the [NonEmptyCollection] generator (see Domain collections). That includes arguments passed to parameters of those types.

Before
NonEmptyList<int> xs = []; // NEL001
ConstrainedList<int, AtLeast<Two>> pair = []; // NEL001
Process([]); // NEL001 when Process takes a NonEmptyList<int>
After
NonEmptyList<int> xs = [0]; // give it an element
List<int> maybeEmpty = []; // or use a type that may be empty

Message: An empty collection expression can never produce a 'NonEmptyList<int>'; this throws at run time.

Only a literally empty [] is reported. [1, 2] and spreads such as [.. other] are not.

Non-empty factory called with an empty argument​

NEL002, error. A factory or constructor receives a sequence the analyzer can prove is always empty, so the call always throws.

Before
var a = NonEmptyList.From(Array.Empty<int>()); // NEL002
var b = new List<int>().ToNonEmptyList(); // NEL002
var c = new NonEmptyList<int>(new List<int>()); // NEL002
var d = NonEmptySet.From(new HashSet<int>()); // NEL002
var e = "".ToNonEmptyList(); // NEL002: a string is an IEnumerable<char>
After
// source is an IEnumerable<int> from your application
var a = NonEmptyList.From(source); // fine: not provably empty
if (source.TryToNonEmptyList(out NonEmptyList<int>? list))
{
// use list
}

Message: 'NonEmptyList.From' is called with an argument that is always empty; this throws at run time.

Calls that are checked. Methods named From, FromSpan, Create, ToNonEmptyList, ToConstrainedList, ToNonEmptySet and ToNonEmptyDictionary on the library's NonEmptyList, ImmutableNonEmptyList, ConstrainedList, NonEmptySet, NonEmptyDictionary and EventStream types and its LINQ and constrained-list extension classes. Also any new of a non-empty type whose single argument goes to an IEnumerable<T> constructor parameter.

Arguments that are provably empty.

ExpressionExample
empty collection expression[]
Empty factoriesArray.Empty<T>(), Enumerable.Empty<T>()
static Empty members of immutable collectionsImmutableArray<T>.Empty, ImmutableList<T>.Empty
empty strings"", string.Empty
zero-length or empty arraysnew T[0], new T[] { }
a new, empty mutable collectionnew List<T>(), new HashSet<T>(), new Dictionary<TKey, TValue>(), new Queue<T>(), new Stack<T>(), new LinkedList<T>(), new SortedSet<T>(), new Collection<T>(), new ObservableCollection<T>(), and target-typed new()

A collection created with constructor arguments, such as new List<int>(source) or new List<int>(capacity: 4) { 1 }, or with initializer elements, is not reported. Neither is new int[n].

Use the total member instead of a throwing LINQ operator​

NEL003, info, with code fix. On a non-empty list, First(), Last() and Aggregate(f) carry an "empty sequence" exception path that the type has already ruled out. The total members Head, Last and Reduce say so in the code.

Before
// xs is a NonEmptyList<int>
int first = xs.First(); // NEL003
int maybe = xs.FirstOrDefault(); // NEL003
int last = xs.Last(); // NEL003
int total = xs.Aggregate((a, b) => a + b); // NEL003
int count = xs.Count(); // NEL003
After
int first = xs.Head;
int maybe = xs.Head;
int last = xs.Last;
int total = xs.Reduce((a, b) => a + b);
int count = xs.Count;
CallReplacementReported on
First(), FirstOrDefault()Headnon-empty lists: NonEmptyList<T>, ImmutableNonEmptyList<T>, ConstrainedList<T, C>, ordered lists, groupings and derived classes
Last(), LastOrDefault()Lastthe same
Aggregate(f)Reduce(f), keeping the argumentthe same
Count(), LongCount()Countthe library's non-empty types that have a Count property, including NonEmptySet<T>; not the lazy NonEmptyEnumerable<T>, which has none

Message: 'First()' on a non-empty list is total; use 'Head' instead, which the type guarantees cannot throw. The fix is titled Use 'Head'.

Overloads with a predicate or a seed, such as xs.First(x => x > 1) and xs.Aggregate(0, (acc, x) => acc + x), are not reported: they can still fail or have no total replacement. Calls on ordinary collections such as List<T> are not reported either.

note

LongCount() returns long and Count is an int. Where the result was assigned to var, the variable's type changes after the fix.

Redundant emptiness check on a non-empty list​

NEL004, info, with code fix. A non-empty list is never empty, so checks such as Any() and Count == 0 always give the same answer. The fix replaces the expression with true or false, which leaves an obvious dead branch for you to delete. If emptiness really is possible at that point, the variable has the wrong type: widen it instead.

Before
// xs is a NonEmptyList<Order>
if (!xs.Any()) // NEL004: xs.Any() is always true
return;

if (xs.Count == 0) // NEL004: always false
throw new InvalidOperationException("No orders");
After
// The checks are gone; xs.Head is safe without them
Order first = xs.Head;
ExpressionAlways
xs.Any()true
xs.Count == 0, xs.Count <= 0, xs.Count < 1false
xs.Count != 0, xs.Count > 0, xs.Count >= 1true
the mirrored forms, such as 0 < xs.Count or 1 > xs.Countthe same verdict
xs.DefaultIfEmpty(), xs.DefaultIfEmpty(value)the list itself (reported, no automatic fix)

The comparisons work with the Count and Length properties and with Count() and LongCount(), against any constant, on any of the library's non-empty types. Checks that the type does not decide, such as xs.Count == 5, xs.Count > 1 or xs.Any(x => x.IsPaid), are not reported.

Message: 'xs.Count == 0' is always false for a non-empty list. The fix is titled Replace with 'false'.

Sequence is not proven non-empty​

NEL005, warning, disabled by default, with code fix. This rule looks the other way: at the plain IEnumerable<T> code that does not use the library yet. It reports First(), Last(), Single(), Max(), Min(), Average(), Aggregate(f), MaxBy(), MinBy() and ElementAt() on a sequence that is not proven non-empty, because each of them throws on an empty sequence. Enable it as shown in Configuring severity.

Before
// scores is an IEnumerable<int>
int highest = scores.Max(); // NEL005
int lowest = Enumerable.Min(scores); // NEL005: the static form is checked too
After the fix
using Masterly.NonEmptyList;

int highest = scores.ToNonEmptyList().Max();
int lowest = Enumerable.Min(scores.ToNonEmptyList());

Message: 'Max()' may throw because 'scores' is not proven non-empty; convert it with ToNonEmptyList() or handle the empty case.

The fix, Prove non-empty with ToNonEmptyList(), wraps the sequence and adds using Masterly.NonEmptyList; when it is missing. The empty case still throws, but now in one explicit place, ToNonEmptyList(), with an ArgumentException that says why. Where empty input is expected, handle it instead:

if (scores.TryToNonEmptyList(out NonEmptyList<int>? proven))
{
int highest = proven.Max(); // int, never throws
}

What counts as proven. The rule stays quiet when the receiver is:

  • one of the library's non-empty types, or anything deriving from one, or a call that returns one;
  • a collection literal with elements: new[] { 1 }, [1, 2], new List<int> { 1 };
  • Enumerable.Range or Enumerable.Repeat with a constant count greater than zero;
  • the result of Prepend, Append or DefaultIfEmpty;
  • Select, OrderBy, OrderByDescending, ThenBy, ThenByDescending, Order, OrderDescending, Reverse, Distinct, ToList, ToArray, AsEnumerable or Cast applied to a proven receiver;
  • Concat or Union where either side is proven.

Overloads that cannot throw are skipped. Aggregate with a seed is total. Max, Min, MaxBy, MinBy and Average return null instead of throwing when the result type is a reference type or a Nullable<T>, so names.MaxBy(n => n.Length) on strings and values.Select(x => (int?)x).Max() are not reported.

tip

Turn NEL005 on for one project, fix or prove the reported calls, then raise it to error. It is the same approach teams used to drive nullable warnings to zero.

Migration fix for CS1503, CS0029, CS0266 and CS1662​

In version 3, NonEmptyList<T> stopped deriving from List<T>. Code that passed a non-empty list where a List<T> is required no longer compiles. Wherever the compiler reports a failed conversion from a non-empty list to List<T>, as an argument (CS1503), an assignment (CS0029, CS0266) or a lambda return value (CS1662), a code fix offers .ToList().

Before (compiler error in v3)
void Save(List<int> ids) { }

// xs is a NonEmptyList<int>
Save(xs); // CS1503
List<int> copy = xs; // CS0029
After the fix
Save(xs.ToList());
List<int> copy = xs.ToList();

The fix is titled Convert with ToList() (NonEmptyList no longer derives from List<T>). It is offered only when the expression is a non-empty list and the target is List<T>; other conversion errors are left alone. Use Fix all in solution to migrate a codebase in one step.

warning

ToList() makes a copy. In 2.x the method received the same instance, so changes it made were visible to the caller. If a method relies on mutating the list it receives, change its parameter to IList<T> instead of applying the fix. When it only reads, IReadOnlyList<T> is the better parameter type and needs no copy. See Upgrading from 2.x.

Generator diagnostics​

The [NonEmptyCollection] source generator reports these errors. When one is reported, nothing is generated for that class. See Domain collections.

NEL101: class is not partial​

'{0}' is marked [NonEmptyCollection] but is not partial; add the 'partial' modifier so the generator can complete it

NEL102: class is nested​

'{0}' is marked [NonEmptyCollection] but is nested in another type; move it to namespace scope

NEL103: invalid bounds​

'{0}': MinCount must be at least 1 or '{0}': MaxCount must be 0 (unbounded) or at least MinCount

NEL104: class declares a base type​

'{0}' declares a base type; the generator supplies NonEmptyList<{1}> as the base

Each diagnostic's help link in the IDE opens the matching section of this page.