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
defaultnon sononull, quindi viene eseguito il ramo che lanciaInvalidOperationException. - Tipi valore nullable (int?, DateTimeOffset?):
default(int?)ènull, quindi rientra nel primo ramo e restituiscenull.
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>, ovveroT?) 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