Perché questo articolo
PHP 8 ha introdotto i union types—comodissimi nei costruttori perché permettono di accettare più tipi senza duplicare la firma. In C# non esistono union types nativi, ma ci sono pattern idiomatici per ottenere la stessa ergonomia con type safety superiore. In questo articolo vediamo come tradurre la feature, quando conviene farlo e con quali compromessi.
PHP: union type nel costruttore in 10 secondi
<?php
class Example {
public function __construct(private int|string $value) {}
}
- Accetta
int
ostring
. - Evita overload multipli.
- L’IDE ti mostra subito le alternative accettate.
Stato dell’arte in C#
- Non esistono union types nativi (C# 12/.NET 8).
- Alternative idiomatiche:
- Overload del costruttore
- Factory statiche + costruttore privato
- Wrapper di union tramite libreria (es.
OneOf<T1,T2>
) - Gerarchie sealed (record/class) + pattern matching
- (solo se necessario) Single ctor
object
+switch
sul tipo
1) Overload del costruttore (la soluzione “C#-first”)
public sealed class Example
{
public int? IntValue { get; }
public string? StringValue { get; }
public Example(int value) => IntValue = value;
public Example(string value) => StringValue = value;
public override string ToString() =>
IntValue is not null ? IntValue.ToString()! : StringValue!;
}
Pro
- API chiare e discoverable.
- Ottimo supporto da parte di IDE e analyzer.
- Niente dipendenze esterne.
Contro
- Più codice se i “rami” crescono.
- Devi curare invarianti duplicati tra overload.
Quando usarlo
- 2–3 alternative, logica semplice, public API.
2) Static Factory + costruttore privato (controllo totale delle invarianti)
public sealed class Money
{
public decimal Value { get; }
public string Currency { get; }
private Money(decimal value, string currency)
{
Value = value;
Currency = currency;
if (Value < 0) throw new ArgumentOutOfRangeException(nameof(value));
if (string.IsNullOrWhiteSpace(Currency)) throw new ArgumentException("Currency required");
}
public static Money From(decimal value, string currency) => new(value, currency);
public static Money From(string amount) // es. "12.34 EUR"
{
var parts = amount.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2 || !decimal.TryParse(parts[0], out var v))
throw new FormatException("Invalid amount");
return new Money(v, parts[1]);
}
}
Pro
- Punto unico per le regole di dominio.
- Nomi parlanti:
From(int)
,From(string)
.
Contro
- Un po’ più verboso dei semplici overload.
Quando usarlo
- Invarianti importanti, parsing/normalizzazione in ingresso.
3) Discriminated Union con OneOf<T1,T2>
(3rd-party, ergonomia simile a PHP)
Pacchetto:
OneOf
(NuGet)
using OneOf;
public sealed class Example
{
private readonly OneOf<int, string> _value;
public Example(OneOf<int, string> value) => _value = value;
public override string ToString() =>
_value.Match(
i => i.ToString(),
s => s
);
}
// Uso
var a = new Example(42);
var b = new Example("ciao");
Pro
- Semantica explicit union pulita.
- Ottimo con pattern matching ed estensione a più tipi.
Contro
- Dipendenza esterna.
- Leggera curva per il team.
Quando usarlo
- Più di 2–3 alternative, API interne tra team abituati a FP/discriminated unions.
4) Gerarchia sealed + pattern matching (modeling “puro”)
public abstract sealed class Input
{
private Input() {}
public sealed record Int(int Value) : Input;
public sealed record Text(string Value) : Input;
}
public sealed class Processor
{
public string Render(Input input) => input switch
{
Input.Int i => $"[{i.Value}]",
Input.Text t => t.Value,
_ => throw new NotSupportedException()
};
}
Pro
- Modeling ricco, estendibile, exhaustive matching sugli analyzer.
- Ottimo per dominio complesso.
Contro
- Più verboso dell’idea “un solo costruttore”.
- Cambia il modello rispetto a “un parametro polimorfo”.
Quando usarlo
- Quando il “tipo” porta comportamenti/regole diverse, non solo dati.
5) Un solo costruttore object
+ switch
sul runtime type (ultima spiaggia)
public sealed class Example
{
public Example(object value)
{
switch (value)
{
case int i: /* ... */ break;
case string s: /* ... */ break;
default: throw new ArgumentException("Unsupported type", nameof(value));
}
}
}
Pro
- Firma unica, simile alla sensazione “union”.
Contro
- Meno type safety; più fragile alle refactor.
- Analyzer/IDE meno d’aiuto.
Quando usarlo
- API interne e temporanee, migrazioni rapide.
Nota su Primary Constructors (C# 12)
I primary constructors rendono più concisa la dichiarazione ma non introducono union types. Puoi combinarli con overload/factory, non li sostituiscono.
public class Sample(string name) // primary ctor
{
public string Name { get; } = name;
public Sample(int id) : this(id.ToString()) {} // overload classico
}
Linee guida pragmatiche
- Public API semplici (2 tipi max) → Overload o Factory.
- Più tipi / composizione di flussi →
OneOf<T...>
o gerarchie sealed. - Dominio con regole forti → Factory + invarianti centralizzati.
- Performance/allocazioni → Preferisci overload (niente wrapper extra).
- Team abituato a FP/pattern matching →
OneOf
o sealed records.
Pro e Contro a colpo d’occhio
PHP union types
- ✅ Concisi, nativi, DX ottima
- ❌ Minor “enforcement” rispetto a discriminated unions modeling-oriented
C# alternative
- ✅ Più controllo (invarianti, modeling, analyzer)
- ❌ Più verbosità / scelte architetturali
Esempio end-to-end: “valore configurabile” accetta int|string
PHP
class Setting {
public function __construct(
private int|string $value
) {}
public function asString(): string {
return is_int($this->value) ? (string)$this->value : $this->value;
}
}
C# – versione overload
public sealed class Setting
{
private readonly string _value;
public Setting(int value) => _value = value.ToString();
public Setting(string value) => _value = value;
public string AsString() => _value;
}
C# – versione OneOf
using OneOf;
public sealed class Setting
{
private readonly OneOf<int, string> _value;
public Setting(OneOf<int, string> value) => _value = value;
public string AsString() => _value.Match(i => i.ToString(), s => s);
}
FAQ
I union types di PHP sono “migliori” degli overload C#?
Dipende dal contesto. In C# gli overload sono idiomatici, leggibili e con ottimo supporto tooling. PHP vince in brevità; C# in controllabilità e modeling.
Esistono discriminated unions nativi in C#?
Non ancora (C# 12/.NET 8). Si simula con sealed hierarchies, OneOf
o pattern matching su record/classi.
Quando scegliere OneOf rispetto agli overload?
Quando i rami superano 2–3 o quando vuoi esplicitare a livello di tipo che il valore è una “union” (e sfruttare Match
/pattern).
Posso usare un solo costruttore object
?
Sì, ma è l’opzione meno type-safe. Usala come soluzione rapida o interna.
Performance: c’è overhead con OneOf?
C’è un piccolo costo (boxing/allocazioni a seconda dei casi). Per path hot preferisci overload/factory.
Conclusione
Questo confronto chiarisce come portare l’ergonomia dei union types di PHP nel mondo C# senza perdere in solidità: non esistendo union nativi, la scelta passa per overload (o factory) quando i casi sono pochi e le invarianti contano, per discriminated union (es. OneOf
) o sealed hierarchy + pattern matching quando i rami crescono o il dominio richiede comportamenti distinti, e solo in ultima istanza per un singolo ctor object
con switch
. I primary constructors migliorano la sintassi, ma non sostituiscono queste strategie. In sintesi: seleziona l’approccio in base a chiarezza dell’API, invarianti di dominio, performance e DX del team; otterrai codice idiomatico in C#, più espressivo oggi e più manutenibile domani. L’articolo può essere utile per chi come me progetta software utilizzando entrambi i linguaggi e ne vuole comprendere le differenze e i punti di contatto.
Risorse per approfondire
- PHP – Union types (manuale ufficiale): https://www.php.net/manual/en/language.types.declarations.php php.net
- PHP – Type system (panoramica + union): https://www.php.net/manual/en/language.types.type-system.php php.net
- C# – Pattern matching (overview): https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/pattern-matching learn.microsoft.com
- C# – Pattern matching (operatori
is
eswitch
): https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/patterns learn.microsoft.com - C# – Record types (reference): https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record learn.microsoft.com
- C# 12 – Primary constructors (tutorial): https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/tutorials/primary-constructors learn.microsoft.com
- C# 12 – Novità (panoramica, primary constructors): https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-12 learn.microsoft.com
- Libreria
OneOf
(Discriminated Union per C#): https://github.com/mcintyre321/OneOf GitHub