Union and Intersection Types Guide

Status: IT0--IT4 are complete. As of TY2, any boxing codegen, the checked cast, and type-of ship for every payload kind (int/bool/float/nil/cstr/ptr, ADTs, and heap-boxed structs). The remaining deferred item is general struct { int tag; union { ... } } tagged-union C emission. See Deferred below.

Union types (A | B) and intersection types (A & B) extend the Turmeric type system with structural type combinations. Together they enable gradual typing, flexible APIs, and type-safe duck typing without wrapper ADTs.

Both features, along with the any type, are enabled by default; no flag is required.


Union Types

A union type (A | B) represents a value that is either A or B. The compiler emits a tagged-union C struct at runtime.

Syntax

;; Named union type
(deftype IntOrString []
  (int | cstr))

;; Inline in a function signature
(defn print-value [x : (int | cstr | bool)] : unit
  (match x
    (i : int)  (println i)
    (s : cstr) (println s)
    (b : bool) (println (if b "true" "false"))))

Nested unions are flattened: (int | (cstr | bool)) becomes (int | cstr | bool).

Pattern Matching on Union Types

Use match to narrow a union-typed value. The elaborator checks exhaustiveness across all union members:

(defn describe [x : (int | cstr)] : cstr
  (match x
    (n : int)  (str "number: " n)
    (s : cstr) (str "string: " s)))

Omitting any member is a compile-time error (TUR_E0301).

Subtyping

A value of type A can be passed anywhere (A | B) is expected (widening). This is handled implicitly at call sites and return positions:

(defn accepts-union [x : (int | cstr)] : unit ...)

(accepts-union 42)       ;; int widens to (int | cstr)
(accepts-union "hello")  ;; cstr widens to (int | cstr)

Typeclass Methods on Unions

When x : (int | cstr), typeclass methods implemented by all union members may be called directly without a match. Methods not in the intersection require an explicit match to narrow first:

;; Show is implemented by both int and cstr
(show x)   ;; ok -- in the instance intersection

;; Arithmetic is only on int; requires narrowing
(match x
  (n : int)  (+ n 1)
  (s : cstr) ...)

Intersection Types

An intersection type (A & B) represents a value that satisfies both A and B. The primary use is combining a concrete type with typeclass constraints.

Syntax

;; Named intersection type
(deftype ReadWrite []
  (Readable & Writable))

;; Inline in a function signature
(defn save [x : (int & Serializable)] : unit
  (file/write (serialize x) "output.bin"))

Subtyping

From a value of intersection type you can project either member:

Typeclass Intersection

Intersection is most useful when one side is a typeclass:

(defclass Serializable [a]
  (serialize [x : a] : cstr))

(defn serialize-int [x : (int & Serializable)] : cstr
  (serialize x))

The value is an int with a Serializable dictionary attached. The elaborator resolves the instance at the intersection type site.

Unsatisfiable Intersections

Intersections of known-disjoint concrete types are rejected statically (TUR_E0350):

;; Compile error: int and cstr are disjoint
(defn bad [x : (int & cstr)] : unit ...)

Intersections involving typeclasses or type variables that cannot be determined disjoint at compile time are permitted and fail during instance resolution.


The any Type

any is the top type: every type is a subtype of any. It is available when either union or intersection flag is active.

(defn debug-print [x : any] : unit
  (println x))

(debug-print 42)      ;; ok
(debug-print "hello") ;; ok
(debug-print true)    ;; ok

any-typed values are represented at codegen as a tur_tagged_t ({ int64_t tag; int64_t val; }): the tag is the payload's TypeKind and the val carries the payload. Immediate values (int/bool/nil) ride the carrier directly, floats are stored as their IEEE-754 bit pattern, pointer payloads (cstr, ptr, ADT handles) store the pointer, and by-value structs are heap-boxed (a malloc'd copy whose pointer rides the carrier). A value is boxed automatically wherever it is widened to any -- at a call argument, a function's : any return position, or an if branch facing an any sibling.

Union simplification: (int | cstr | any) simplifies to any.


Gradual Typing

Union types and any enable a gradual typing path:

;; Start untyped
(defn debug-print [x : any] : unit
  (println x))

;; Narrow gradually as types become known
(defn typed-print [x : (int | cstr)] : unit
  (debug-print x))

Boxing, cast, and type-of

A value widened to any is boxed into a tur_tagged_t that records the payload's runtime type. Two forms read that box back:

(defn box-it [] : any "hello")

(defn main [] : int
  (let [a (box-it)]
    (println (type-of a))    ;; => cstr
    (println (cast a cstr))  ;; => hello
    ;; (cast a int)          ;; would panic: any holds cstr, not int
    0))

By-value structs are heap-boxed on widening (a malloc'd copy) and unboxed by dereference on cast; ADTs and cstr are pointer-carried and ride the carrier directly; floats are stored by their bit pattern so no precision is lost.

Note: the struct heap-box is owned by the any value's (untracked) lifetime, so the malloc'd copy is not freed -- widening a struct to any leaks one allocation per widen. This is acceptable for the gradual-typing use cases any targets; if you need a struct in any on a hot path, prefer a pointer/ADT payload, which is carrier-resident and allocation-free.


ADTs and Unions (interop via any, not a desugar)

An early plan proposed desugaring defdata into a union type internally so the two share one code path. That internal unification is not pursued: the union machinery is monomorphic, closed, and non-recursive, while every defdata in the tree is parametric (Either [L R]), higher-kinded (Fix [^f], Free [^f a]), recursive, or a GADT (Nat). Lowering those onto today's union representation would mean rebuilding parametric/HKT/recursive/GADT sum-type support on top of unions -- a far larger change with no user-visible payoff.

The user-facing goal that desugar was meant to deliver -- ADT values participating in union-style dispatch -- already ships through the any top type. An ADT value widens to any (boxing codegen), reports its kind via type-of ("adt"), and lands back in match through a checked cast. See the defdata-as-union and any-box-adt fixtures.


Error Codes

Code Message
TUR_E0300 Union type mismatch: expected {expected}, got {actual}
TUR_E0301 Non-exhaustive pattern match on union type {type} -- missing arm for {variant}
TUR_E0350 Intersection type unsatisfiable: no value can be both {A} and {B}
TUR_E0351 Value of type {actual} does not satisfy intersection member {missing}

Known Limitations

Tagged Union Overhead

TypeScript's union types are zero-cost (erased). Turmeric emits struct { int tag; union { A a; B b; } data; }. Every union-typed value pays one extra int for the tag plus alignment padding to the largest member. This matters for arrays, struct fields, and cache pressure.

Widening (passing 42 where (int | cstr) is expected) requires constructing the tagged union at the call site -- it is not a free annotation.

if-Guard Narrowing (any)

Flow-sensitive narrowing works in if guards on an any-typed variable. A type-test guard in the condition refines the variable to the tested type inside the then-branch, so it can be used at that type with no explicit cast. Two guard shapes are recognized:

;; (is? x T) -- the dedicated type-test predicate
(defn bump [x : any] : int
  (if (is? x int)
    (+ x 1)     ;; x is narrowed to int here -- no cast needed
    0))

;; (= (type-of x) "T") -- type-of compared against a string literal
(if (= (type-of x) "int")
  (+ x 1)
  0)

Chaining handles multi-type dispatch:

(defn describe [v : any] : cstr
  (if (is? v int)   "int"
    (if (is? v bool) "bool"
      (if (is? v Point) "point" "other"))))

(is? x T) is also a plain boolean predicate (it requires an any-typed argument, like type-of and cast). The runtime check compares the value's box tag to T; T may be a primitive, struct, or ADT name.

Supported guard shapes (narrow): a direct (is? x T) or (= (type-of x) "T") test on a single any variable, used as the whole if condition. Unsupported (do not narrow): negation ((not (is? x T))), conjunction/disjunction of tests, the else-branch complement, and tests on union (A | B) variables. For unions, use match, which narrows exhaustively:

(match x
  (n : int)  (+ n 1)
  (s : cstr) ...)

Intersection is Constraint-Only

TypeScript and Scala 3 merge struct fields across intersections ({ x: int } & { y: bool } gives { x: int; y: bool }). Turmeric statically rejects intersections of two known-disjoint concrete types. Intersection is only useful when at least one side is a typeclass.

Closed Unions

Union types are closed -- the member set is fixed at definition time. A library returning (int | ParseError) cannot be transparently composed with one returning (bool | ParseError) without an explicit adapter.

Variance with Generics

Variance for type constructors containing union or intersection types is not yet specified. Passing (vec (int | cstr)) where (vec int) is expected may produce unexpected behaviour and will be addressed before these features are enabled by default.


Shipped in TY2

Item Notes
any boxing codegen All payload kinds box: immediates ride the carrier, floats by bit pattern, cstr/ptr/ADT by pointer, by-value structs heap-boxed. Boxing happens at every widening site (call arg, : any return, if branch).
(cast x : T) Checked downcast from any; verifies the box tag and panics on mismatch. T may be a primitive, struct, or ADT name.
(type-of x) Returns the payload's type name ("int", ..., "struct", "adt") at kind granularity.

Deferred

The following IT4 items are not yet implemented:

Item Notes
Tagged union C codegen General struct { int tag; union { A a; B b; } data; } emission for (A \| B) unions (the any top type ships via tur_tagged_t)
Per-name type-of/cast granularity type-of reports "struct"/"adt", not the specific struct/ADT name; cast checks at that same granularity
ADT-as-union sugar Not pursued -- infeasible against monomorphic unions (defdata is parametric/HKT/recursive/GADT). ADTs already interoperate with unions via any boxing; see ADTs and Unions.
Instance intersection on unions Deferred failure during instance resolution may be hard to diagnose

See Also