Table of Contents

Unions

Unions are one of Raven's central data-modeling features. They describe values that can take one of several known forms, with each form able to carry its own data. Their closed set of alternatives works directly with pattern matching and exhaustiveness checking.

Raven uses two complementary terms for its existing union forms:

  • An ad-hoc union is written directly as a type expression, such as string | null. It does not introduce a new declared name.
  • A nominal union is introduced by a union declaration. It is a named, declared type and comes in parenthesized and case-declaration forms.

This terminology describes the existing forms; it does not introduce another kind of union or change their semantics.

Ad-hoc unions

Ad-hoc unions combine existing types directly when a value may have one of several forms.

The type syntax T1 | T2 is shorthand for System.Union<T1, T2>. The same spelling supports three, four, and five alternatives. The standard union carriers are provided by Raven.Core as a temporary bridge until .NET adopts a standard union type.

Nominal unions

Use a nominal union when a fixed set of variants has a domain identity of its own, especially when each variant can carry different data.

Nominal unions define carrier types with a fixed set of variants. Variants are declared in one of two syntactic forms: as types in the parenthesized form, or by case declarations in the case-declaration form. The latter is also called the body form when discussing its syntax. A nominal union value is always stored as the declared carrier type, and variant values convert to that carrier implicitly when required.

Plain union declarations synthesize struct carriers by default. Use union class when a reference carrier is intended, such as for APIs that must not expose the default struct-union state.

Like classes and structs, a union carrier can implement one or more interfaces by listing them after a colon. Every entry must resolve to an interface; unions cannot inherit a class because their carrier representation is selected by the union declaration itself.

interface IFailure {
    val Message: string
}

union Failure: IFailure {
    case Unknown

    val Message: string => "Unknown failure"
}

The interface belongs to the union carrier. Generated case types do not each declare the interface independently. The carrier must implement every required interface member in the same way as an ordinary class or struct.

Nominal union form Syntax Variant declarations Resulting variants Typical pattern form
Parenthesized union Payment(Cash \| Card) parenthesized types existing types (Cash, Card) Cash(...), Card(...)
Body form union LookupResult { case Found(id: int) case Missing } case declarations synthesized case types (Found, Missing) Found(...), Missing

Parenthesized unions

The parenthesized form declares variants by listing existing nominal or primitive types. Raven does not synthesize additional types for these variants.

record Cash(amount: decimal)
record Card(last4: string)

union Payment(Cash | Card)
union OptionalPayment(Cash | Card?)

let paidInCash: Payment = Cash(12.50m)
let paidByCard: Payment = Card("4242")
  • Each listed type declares one variant in the carrier's closed variant set.
  • A parenthesized union must declare at least two variant types. A single type does not form a union.
  • null cannot declare a variant. Use nullable annotations such as T? when a parenthesized type variant may actively carry null.
  • Pattern matching uses ordinary patterns over those variant types. Nullable variant patterns do not cover the null branch for exhaustiveness; include a null arm when the union contents may be null.
  • Construction occurs by constructing a listed variant type and then converting it to the carrier when needed.

Case-declaration unions

The case-declaration form declares a carrier with an ordinary member body. That member body may contain case declarations alongside other members such as methods and properties. Each case declaration declares one variant and synthesizes its named case type. This form is also called the body form or a tagged union because each value belongs to one named variant in the union's closed variant set.

union LookupResult {
    case Found(int)
    case Missing

    func Describe() -> string {
        match self {
            Found(let id) => "found $id"
            Missing => "missing"
        }
    }
}

let found: LookupResult = Found(42)
let missing: LookupResult = Missing
  • Each case declaration declares one synthesized case type.
  • A case-declaration union must declare at least one case. Other members do not count as variants.
  • A case payload uses either positional types (case Pair(int, string)) or named members (case Range(start: int, end: int)). A single case cannot mix the two forms.
  • Positional payloads project stable generated members: a single value is exposed as Value, while multiple values are exposed as Item1, Item2, and so on. Normal Raven signatures preserve the declaration spelling and do not display those generated names.
  • Case-declaration unions may also declare computed properties, indexers, and ordinary methods in the same body. Computed static properties are permitted.
  • Additional members may project information from, or act on, the value held by the union. They cannot modify the union value itself. The compiler-generated Value representation is the union's only instance storage.
  • Other member kinds are not permitted initially. This includes fields, constants, events, constructors and initializer blocks, operators and conversions, finalizers, and nested types.
  • All authored properties must be computed and cannot declare initializers, auto-accessors, or field-backed accessors.
  • These restrictions apply to Raven union declarations. A manually authored CLR type that implements the .NET union ABI owns its representation and may use other members and storage, provided it maintains that ABI's invariants.
  • case declarations are valid only inside union declarations.
  • Case references may use Union.Case, .Case, or unqualified Case when resolution is unambiguous.
  • A comma or semicolon after a case is optional; when present it terminates that case declaration.
  • Generic unions are allowed in both forms, for example union Result<T, E> { case Ok(value: T) case Error(error: E) }.
  • union declarations may be partial. Cases and ordinary members may be distributed across partial declarations of the same union. A union with no cases reports one cardinality diagnostic on its first declaration in compilation source order.
  • The carrier reserves the member names Value and HasValue for synthesized members.
  • As with records, an authored override ToString() suppresses the synthesized union ToString().
  • The synthesized representation is derived from the statically known carrier, active case, and payload members. It does not use runtime reflection. Generic type arguments are therefore omitted from the union name: a closed Result<int, string> value is displayed as Result.Ok(42), not Result<Int32, String>.Ok(42). String and character payloads remain quoted, including when their declared payload type is a type parameter.
  • Authored Equals, GetHashCode, and equality operators on unions are currently rejected.

Line-continuation details for leading-dot case forms are defined in Control flow: Line continuations.

Constructing and extracting cases

Case construction creates a case value first. Conversion to the carrier happens when the surrounding context requires the union type.

let ok: Result<int, string> = Ok(99)
let err = Result<int, string>.Error("boom")

let outcome: Either<int, string> = 42
let left = (int)outcome
  • Case(...) constructs the case value directly.
  • Union.Case(...) resolves the case through the union surface and constructs the same case value.
  • .Case(...) resolves the case from the target type's union case set.
  • Unqualified Case(...) is valid only when case lookup is unambiguous. Normal lexical lookup wins before union-case lookup.
  • Every union carrier exposes a conventional Value property whose runtime value is the currently stored member or case value.
  • Value has a C#-compatible object or object? shape. This property shape is not the source of truth for nullable active contents; Raven derives that from the case construction surface.
  • Every union carrier also exposes HasValue: bool, which follows the C# union access pattern and reports whether Value is not null.
  • Public one-parameter constructors define the C#-compatible variant set. TryGetValue(out CaseType) exposes carrier inspection for each case type but does not add extra variants when constructors already define that set.
  • An explicit cast from the carrier to a variant type succeeds only when the carrier currently holds that variant; otherwise it throws InvalidCastException.
  • Pattern matching is preferred to explicit casts for ordinary extraction.

In pattern position:

  • Body-form unions use Case(...) or Case by default when the case name is unambiguous.
  • Union.Case(...) is available for explicit qualification.
  • .Case(...) remains available as target-typed shorthand when the scrutinee already determines the union.
  • Parenthesized unions use ordinary patterns over their declared variant types.

The generated prelude imports the standard Result and Option case types into scope. In ordinary Raven files, prefer the unqualified forms Ok, Error, Some, and None. Use Union.Case for explicit qualification or .Case when target-typed member binding is useful. Projects that disable the prelude must import the cases or use one of those qualified forms.

Case-construction forms

Raven supports the following equivalent case-construction forms:

// Case type construction, when the case is imported
Ok(2)
Ok<int>(2)

// Union-member sugar
Result<int, MyError>.Ok(2)

// Target-typed member-binding sugar
let r: Result<int, MyError> = .Ok(2)

Binding model:

  • Case(...) constructs the case type value directly when the case is in scope through a type wildcard import, direct case import, alias, or generated prelude import.
  • Unqualified Case(...) is allowed when imported case resolution is unambiguous; otherwise a qualified form (Union.Case(...)) or target-typed member form (.Case(...)) is required.
  • Union.Case(...) resolves Case from the union’s declared case set, then constructs the case value.
  • .Case(...) resolves Case from the target type’s union case set.
  • For an unqualified identifier in expression position, ordinary lexical lookup wins before imported union-case lookup: locals and parameters first, then visible instance/static members and imported symbols, then unqualified union cases made visible by imports.
  • If a union value is required, case-to-union conversion applies implicitly by constructing the matching carrier value from the case value.

Union invariants:

  • Plain union declares a struct carrier by default. union struct is explicit spelling for the same carrier category, and union class opts into a reference carrier.
  • Case constructors are independent case-type constructors; they are not rebound as union constructors.
  • union struct reserves its default state as an uninitialized carrier. For default(U), Value is null, HasValue is false, and no case is active until a union constructor populates the carrier.
  • The default union struct carrier state is not a formal union variant. Pattern exhaustiveness checks the declared variant set only. A source-exhaustive match retains a defensive runtime fallback so values forced into the default state through metadata or interop cannot fall through silently.
  • Nullable union carriers (U?) add the nullable wrapper's null value to the source match domain. A match over U? must cover the declared union cases and null, or use a catch-all. This nullable null value is separate from the inactive/default carrier state of union struct.
  • Function parameters and self of union struct type are active inside the callee because the call boundary rejects possibly inactive arguments before entry. Matching or forwarding them does not require an extra source catch-all.
  • Fields and properties of union struct type are storage/interop boundaries that may still contain the inactive/default carrier unless narrowed by local flow. Passing or returning one of those values requires an active-state proof at the boundary rather than an extra source match arm.
  • Local values initialized from a union variant or assigned an active union value are known active. Matching such a local requires only the declared variant set; a catch-all arm after all cases is redundant.
  • Passing a union struct value to a union struct parameter requires the argument to be known active at the call site. A value that flow analysis knows may still be the inactive/default carrier is rejected before entering the callee. Omitting an optional argument whose default value is the carrier default is also rejected at the call site.
  • Returning a union struct value from a function or property requires the value to be known active at the return boundary. A value that flow analysis knows may still be the inactive/default carrier is rejected before it leaves the declaring member.
  • union class does not have that extra carrier state; a class carrier exists only after construction through one of its variants or constructors.
  • For ordinary class carriers with no nullable active member state, null is not a valid pseudo-case for Value.
  • HasValue follows the public C# union access pattern and is equivalent to Value != null. Raven does not expose active-null contents as a separate public HasValue state.
  • Union wrapping is represented by carrier construction from a case value.
  • Compatibility is decided by case-to-union conversion rules (including payload subtype-to-supertype widening where valid).

Type argument behavior:

  • Case type arguments may be explicit (Ok<int>(2)) or inferred from constructor arguments (Ok(2)).
  • Union type arguments are taken from explicit receiver types (Result<int, MyError>.Ok(2)) or from target typing (let r: Result<int, MyError> = .Ok(2)).

For every case Case, assigning, returning, or passing a case value automatically produces the union carrier through case-to-union conversion. Member-qualified case construction still constructs the case first and then converts to the carrier when the surrounding context requires the union value:

let ok: Result<int, string> = Ok(99)          // implicit case-to-union conversion
let err = Result<int, string>.Error("boom")
Console.WriteLine(ok)

Each case struct also exposes its payload via get-only properties and a Deconstruct(out ...) method matching the payload order. These synthesized members make deconstruction and positional patterns available in Raven and improve interoperability with other .NET languages.

Pattern matching exhaustively checks every case; see Pattern matching for case-pattern forms (unqualified Case, Union.Case, and .Case) inside match expressions.

Choosing a closed-shape type

Closed-shape types let the compiler know every possible alternative, which makes exhaustive matching possible.

Raven has three primary ways to model a finite, closed set of alternatives:

  1. Algebraic data types (ADTs) expressed as unions (union)
  2. Enums (enum)
  3. Generalized data types (GDTs) expressed as sealed class or interface hierarchies

Each can participate in exhaustiveness analysis for match, and each represents a known closed shape at compile time. The key difference is modeling style:

Use this When you need
union ADT Algebraic data modeling with explicit case payloads, carrier-based construction/extraction (Ok(...), .Ok(...), TryGetValue), and closed alternatives.
enum Named constants over a single integral value domain, numeric interop, flags-style values, or compact status codes with no case payloads.
sealed-hierarchy GDT Object-oriented subtype modeling with shared base behavior, virtual/interface-style design, ordinary named case types, and class or interface hierarchy semantics.

Choosing between them

Choose unions when:

  • the alternatives are primarily data cases,
  • payloads are part of the case definition,
  • construction/pattern matching is the dominant interaction,
  • parameterless alternatives are still semantic tagged cases rather than named numeric constants.

Choose enums when:

  • every alternative is just a name for an integral constant,
  • numeric representation, ordering, bitwise flags, or .NET enum interop matters,
  • no alternative carries payload data or needs a distinct generated case type.

Choose sealed hierarchies when:

  • you are modeling a class family,
  • variants share behavior through a base type,
  • subtype polymorphism is part of the design.

Both are "closed-shape" constructs; prefer the one that matches your domain modeling style rather than forcing a single pattern for all cases.