λ1012: Prefer the …OrNone method over …OrDefault with a null check

FirstOrDefault, LastOrDefault, SingleOrDefault and ElementAtOrDefault signal a missing element with null. Funcky's FirstOrNone, LastOrNone, SingleOrNone and ElementAtOrNone return an Option<T> instead.

Cause

The result of one of the LINQ …OrDefault methods is immediately tested for null:

  • xs.FirstOrDefault() ?? fallback
  • xs.FirstOrDefault()?.Member
  • xs.FirstOrDefault() is null, is not null, == null, != null
  • xs.FirstOrDefault() is { } x, is not { } x, is string s

The rule applies to sequences of a reference type. With nullable reference types enabled, sequences of a nullable element type (IEnumerable<string?>) are left alone: there a null result is ambiguous between "no element" and "the element is null". The overloads with an explicit default value (FirstOrDefault(fallback)) and the IQueryable variants are not reported either.

Reason for rule

A null has to be checked by every caller, and nothing forces the check. FirstOrNone returns an Option<T>, so the missing element is a None and the handling is a GetOrElse, Select, Where or Match on the option. The code says what happens when there is no element, and the compiler says when that case is forgotten.

How to fix violations

Replace the …OrDefault call with its …OrNone counterpart and handle the option. For xs.FirstOrDefault() ?? fallback the code fix produces xs.FirstOrNone().GetOrElse(fallback). A ?? evaluates its fallback only when the left side is null, so a fallback that does work (a call, an object creation, an interpolated string, a throw) is passed as a function, GetOrElse(() => CreateFallback()), and keeps that behaviour. There is no fix when the ?? widens the type (values.FirstOrDefault() ?? new object() is an object, which Option<string>.GetOrElse cannot return) or when the fallback cannot move into a lambda (an await, a ref parameter). The other shapes have no single idiomatic replacement, so they are reported without a fix:

BeforeAfter
xs.FirstOrDefault() ?? fallbackxs.FirstOrNone().GetOrElse(fallback)
xs.FirstOrDefault()?.Namexs.FirstOrNone().Select(x => x.Name)
xs.FirstOrDefault() is null!xs.Any() or xs.FirstOrNone().Match(none: true, some: False)
if (xs.FirstOrDefault() is { } x) { A(x); } else { B(); }xs.FirstOrNone().Switch(none: B, some: A)

Examples

Disallowed

static string Example(IEnumerable<string> names)
    => names.FirstOrDefault() ?? "nobody";

static int Example(IEnumerable<string> names)
    => names.SingleOrDefault()?.Length ?? 0;

static void Example(IEnumerable<string> names)
{
    if (names.LastOrDefault() is { } last)
    {
        Console.WriteLine(last);
    }
}

Allowed

static string Example(IEnumerable<string> names)
    => names.FirstOrNone().GetOrElse("nobody");

static int Example(IEnumerable<string> names)
    => names.SingleOrNone().Select(name => name.Length).GetOrElse(0);

static void Example(IEnumerable<string> names)
    => names.LastOrNone().Switch(none: NoOperation, some: Console.WriteLine);

// A null element is a value here, not a missing element.
static string Example(IEnumerable<string?> names)
    => names.FirstOrDefault() ?? "nobody";