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
uniondeclaration. 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.
nullcannot declare a variant. Use nullable annotations such asT?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
nullbranch for exhaustiveness; include anullarm 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
casedeclaration 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 asItem1,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
Valuerepresentation 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
uniondeclarations. 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. casedeclarations are valid only insideuniondeclarations.- Case references may use
Union.Case,.Case, or unqualifiedCasewhen 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) }. uniondeclarations may bepartial. 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
ValueandHasValuefor synthesized members. - As with records, an authored
override ToString()suppresses the synthesized unionToString(). - 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 asResult.Ok(42), notResult<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
Valueproperty whose runtime value is the currently stored member or case value. Valuehas a C#-compatibleobjectorobject?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 whetherValueis notnull. - 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(...)orCaseby 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(...)resolvesCasefrom the union’s declared case set, then constructs the case value..Case(...)resolvesCasefrom 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
uniondeclares a struct carrier by default.union structis explicit spelling for the same carrier category, andunion classopts into a reference carrier. - Case constructors are independent case-type constructors; they are not rebound as union constructors.
union structreserves its default state as an uninitialized carrier. Fordefault(U),Valueisnull,HasValueisfalse, and no case is active until a union constructor populates the carrier.- The default
union structcarrier 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'snullvalue to the source match domain. A match overU?must cover the declared union cases andnull, or use a catch-all. This nullablenullvalue is separate from the inactive/default carrier state ofunion struct. - Function parameters and
selfofunion structtype 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 structtype 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 structvalue to aunion structparameter 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 structvalue 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 classdoes 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,
nullis not a valid pseudo-case forValue. HasValuefollows the public C# union access pattern and is equivalent toValue != null. Raven does not expose active-null contents as a separate publicHasValuestate.- 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:
- Algebraic data types (ADTs) expressed as unions (
union) - Enums (
enum) - 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.