immagine evocativa degli union types

Union Types nel costruttore: PHP vs C#

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 o string.
  • 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:
    1. Overload del costruttore
    2. Factory statiche + costruttore privato
    3. Wrapper di union tramite libreria (es. OneOf<T1,T2>)
    4. Gerarchie sealed (record/class) + pattern matching
    5. (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 flussiOneOf<T...> o gerarchie sealed.
  • Dominio con regole fortiFactory + invarianti centralizzati.
  • Performance/allocazioni → Preferisci overload (niente wrapper extra).
  • Team abituato a FP/pattern matchingOneOf 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


Pubblicato

in

da

Tag: