Language Design: Four Kinds of Unions

Published on 2021-08-26. Last updated on 2026-07-28

Overview

Syntactic Wrapping No Syntactic Wrapping
No Runtime Tags untagged union
(C union, C++ union, Rust union)
union type
(TypeScript union type)
Runtime Tags discriminated union/tagged union
(Rust enum, F# discriminated union)
type union
(Algol united mode, Core union, C# union)


Untagged Unions

Some languages like C, C++ or Rust provide untagged unions, where the chosen variant has to be specified on creation:

union Pet {
  cat: Cat,
  dog: Dog
}
let pet = Pet { cat: Cat("Molly", 9) }

Values of untagged unions do not contain metadata (runtime tags) to distinguish variants (though such information can be manually included as an additional field).

This approach usually requires that the expected variant is assumed/asserted when accessing it from the union value.

Discriminated Unions

A “traditional” discriminated union definition as it exists in various languages defines both the enum itself (Pet), as well as its variants (Cat and Dog).

enum Pet {
  Cat(name: String, lives: Int),
  Dog(name: String, age: Int)
}
let pet: Pet = Cat("Molly", 9)

A discriminated union value contains a tag to allow telling its variants apart – even in cases where the types are them same (such as in Result[String, String]).

Union Types

Other languages provide union types where the definition of the union type (Pet) refers to existing types in scope for its variants (Cat and Dog).

type Pet = Cat | Dog
let pet: Pet = Cat("Molly", 9)

A value of such a union does not contain metadata (runtime tags) to tell its variants apart. This means that type Num = Int | Int does not allow detecting whether Int refers to the first or the second variant; the definition is equivalent to type Num = Int.

Type Unions

The combination of unions with runtime tag but without syntactic wrapping has existed in various languages, though no common, language-spanning name has been established for this concept.

United modes in Algol 68:

STRUCT Cat (STRING name, INT lives);
STRUCT Dog (STRING name, INT years);
MODE Pet = UNION (Cat, Dog);

Unions in core:

class Cat(name: String, lives: Int)
class Dog(name: String, years: Int)
union Pet of Cat, Dog

Unions as proposed for C# 15:

public record Cat(string Name, long lives);
public record Dog(string Name, long years);
public union Pet(Cat, Dog);

All these define a union Pet that refers to existing types Cat and Dog.

An instance of Cat can be directly assigned to pet (of union type Pet) – without syntactic wrapping:

// core
let pet: Pet = Cat("Molly", 9)

Intuitively, this works similarly to permits clauses of sealed interfaces in Java in the sense that

sealed interface Pet permits Cat, Dog { ... }

does not define Cat or Dog, but refers to existing Cat and Dog types in scope.1

  1. Unlike sealed interfaces in Java though, in the proposed union design Cat and Dog are not subtypes of Pet. ↩