Programmazione

LINQ Max e i tipi valore nullable in C#: il comportamento inatteso che causa eccezioni a runtime

Dario Fadda Aprile 18, 2026

Chi usa LINQ in C# con una certa frequenza prima o poi si imbatte in un comportamento del metodo Max che, a prima vista, appare del tutto irrazionale: due chiamate sintatticamente quasi identiche producono risultati radicalmente diversi, e una delle due lancia un’eccezione a runtime su una sequenza vuota. Vediamo perché accade e come risolverlo.

Il problema: Max con proiezioni su tipi diversi

Consideriamo questo tipo record:

public record WithValues(string Label, int Number, DateTimeOffset Date);

Ora proviamo a chiamare Max su un array vuoto con tre proiezioni diverse:

WithValues[] empty = [];

string? maxLabel  = empty.Max(x => x.Label);   // Restituisce null
var     maxDate   = empty.Max(d => d.Date);     // Lancia InvalidOperationException
int?    maxNumber = empty.Max(x => x.Number);   // Lancia InvalidOperationException

La firma del metodo è:

public static TResult? Max<TSource, TResult>(
    this IEnumerable<TSource> source,
    Func<TSource, TResult> selector);

Il tipo di ritorno è dichiarato come TResult? — nullable. Eppure il comportamento cambia radicalmente in base a ciò che la funzione di proiezione restituisce.

La causa: come Max distingue i tipi internamente

Internamente, l’implementazione di Max usa un test per decidere come comportarsi su una sequenza vuota:

TResult val = default;
if (val == null)
{
    // Tipo reference o nullable: restituisce null per sequenze vuote
}
else
{
    // Tipo valore non nullable: lancia eccezione per sequenze vuote
}

Questo porta a tre comportamenti distinti:

  • Tipi reference (string): default(string) è null, quindi il ramo “restituisce null” viene eseguito correttamente.
  • Tipi valore non nullable (int, DateTimeOffset): i loro default non sono null, quindi viene eseguito il ramo che lancia InvalidOperationException.
  • Tipi valore nullable (int?, DateTimeOffset?): default(int?) è null, quindi rientra nel primo ramo e restituisce null.

Perché questo design?

Il ragionamento alla base è comprensibile: per i tipi valore, restituire il valore di default per una sequenza vuota sarebbe ambiguo. Se Max su una lista vuota di interi restituisse 0, come distinguere tra “il massimo è zero” e “la lista era vuota”? L’eccezione rimuove questa ambiguità, ma lo fa in modo sorprendente data la firma del metodo.

Il problema di fondo è storico: C# ha sviluppato la nullabilità in tre fasi distinte che non si integrano uniformemente nel codice generico:

  • I tipi reference erano sempre nullable (fin dalle origini del linguaggio).
  • I tipi valore nullable (Nullable<T>, ovvero T?) sono stati introdotti in C# 2.0.
  • Le nullable reference types (NRT) sono arrivate in C# 8.0 come feature opzionale.

La soluzione: cast esplicito al tipo nullable

La correzione è semplice: basta fare il cast esplicito della proiezione al tipo nullable corrispondente.

// Invece di:
var maxDate    = empty.Max(d => d.Date);
int? maxNumber = empty.Max(x => x.Number);

// Usare:
DateTimeOffset? maxDate    = empty.Max(d => (DateTimeOffset?)d.Date);
int?            maxNumber  = empty.Max(x => (int?)x.Number);

Il cast forza il compilatore a inferire TResult = DateTimeOffset? (o int?), il cui default è null, e Max segue quindi il percorso corretto, restituendo null invece di lanciare un’eccezione.

Alternativa: DefaultIfEmpty

Un’altra soluzione è preporre DefaultIfEmpty prima di Max:

int maxNumber = empty
    .Select(x => x.Number)
    .DefaultIfEmpty(0)
    .Max();

Questa alternativa è utile quando si vuole un valore di fallback esplicito invece di null, ma va usata con attenzione: il valore di default deve essere scelto consapevolmente per non introdurre ambiguità semantica nel risultato.

Quando questo si manifesta in produzione

Il problema diventa insidioso quando si lavora con sequenze filtrate dinamicamente che possono risultare vuote in certi contesti, o quando il codice viene testato sempre con dati non vuoti e crasha in produzione su edge case inaspettati. Una buona pratica difensiva è usare sempre il cast a tipo nullable quando si usa Max (o Min, che ha lo stesso comportamento) su tipi valore, a meno che non si abbia la certezza assoluta che la sequenza non sarà mai vuota.

Conclusioni

Il comportamento di LINQ.Max con i tipi valore è uno di quei casi in cui l’implementazione è tecnicamente coerente, ma la firma del metodo lascia intendere qualcosa di diverso da ciò che avviene a runtime. La regola da ricordare è semplice: se la proiezione restituisce un tipo valore non nullable e la sequenza potrebbe essere vuota, usare sempre un cast esplicito a T?. Un piccolo accorgimento che previene eccezioni difficili da diagnosticare in produzione.


Fonte originale: LINQ Max and nullable value types (Ian Griffiths, endjin.com) — tratto dal briefing Dew Drop del 17 aprile 2026

💬 Unisciti alla discussione!


Questo è un blog del Fediverso: puoi trovare quindi questo articolo ovunque con @blog@spcnet.it e ogni commento/risposta apparirà qui sotto.

Se vuoi commentare su LINQ Max e i tipi valore nullable in C#: il comportamento inatteso che causa eccezioni a runtime, utilizza la discussione sul Forum.
Condividi la tua esperienza, confrontati con altri professionisti e approfondisci i dettagli tecnici nel nostro 👉 forum community