Status:
anyboxing codegen, the checkedcast, andtype-ofship for every payload kind (int/bool/float/nil/cstr/ptr, ADTs, and heap-boxed structs), at per-TYPE granularity --type-ofnames the struct or ADT andcastrejects a different one. The one deferred item is generalstruct { 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.
A union type (A | B) represents a value that is either A or B. At runtime
a union-typed value is carried as a tur_tagged_t ({ int64_t tag; int64_t val; }) --
a tag word plus one 64-bit payload slot.
;; 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).
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).
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)
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) ...)
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.
;; Named intersection type
(deftype ReadWrite []
(Readable & Writable))
;; Inline in a function signature
(defn save [x : (int & Serializable)] : unit
(file/write (serialize x) "output.bin"))
From a value of intersection type you can project either member:
(A & B) <: A and (A & B) <: BA accepts a value of type (A & B)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.
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.
any Typeany is the top type: every type is a subtype of any. Like unions and
intersections, it is enabled by default.
(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.
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))
cast, and type-ofA value widened to any is boxed into a tur_tagged_t that records the
payload's runtime type. Two forms read that box back:
(type-of x) returns the payload's type name as a cstr. A primitive
reports its kind -- "int", "bool", "float", "cstr", "ptr" -- and a
struct or ADT reports its own name: "Point", "Shape". (It used to
answer "struct" for every struct and "adt" for every ADT.)(cast x : T) is a checked downcast. It verifies the box tag matches
T and returns the unboxed value; on a mismatch it panics (aborts with a
message like cast: any holds cstr, not int). T may be a primitive type
name or a struct/ADT name.(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))
The check is by TYPE, not by kind, so two struct types are distinguishable:
(defstruct Point [x : int y : int])
(defstruct Other [a : int b : int])
(let [a (:: (make-struct Point 3 4) any)]
(println (type-of a)) ;; => Point
(println (is? a Point)) ;; => true
(println (is? a Other)) ;; => false
;; (cast a Other) ;; panics: cast: any holds Point, not Other
(.x (cast a Point))) ;; => 3
Under the hood the box tag is a per-monomorph id for a struct/ADT payload (a
primitive keeps its TypeKind), and the program carries a table naming the ids
it allocated. (Box int) and (Box float) get distinct ids; type-of reports
the head name ("Box") for both.
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: widening a struct to
anydoes not leak. The heap box a by-value payload needs is owned in every position it can occupy:
- As a call argument, when the callee neither retains the value nor can suspend, there is no allocation at all -- the copy lives in the caller's frame.
- Bound to a local that does not escape, the box is released when the local dies: at scope exit, at its sole consuming use when the scope's end is unreachable, and at a
returnor a tail-call back-edge otherwise. A local thatis?narrows is covered too, even though the narrowing rebinds the name.- As a temporary -- never named -- produced by a function whose body ends in a widen, or forwarded through a pure passthrough, and consumed by a non-retaining, effect-free call.
A callee that retains the value, has an inline-C body, may suspend, or is called indirectly keeps the box by design: the caller cannot know when it dies. That is the one place a struct payload still costs an allocation, and the guidance there is unchanged -- prefer a pointer/ADT payload, which is carrier-resident and allocation-free.
Pinned leak-clean under LeakSanitizer by
tests/fixtures/any-widen-frame-box,any-widen-local-drop,any-widen-temp-drop,any-widen-drop-past-early-exit, andany-widen-drop-narrowed; the shapes that must NOT be dropped byany-widen-retaining-callee. Seedocs/archive/any-struct-box-leak-per-widen.md.
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.
| 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} |
TypeScript's union types are zero-cost (erased). Turmeric carries every
union-typed value as a tur_tagged_t ({ int64_t tag; int64_t val; }): one
extra 64-bit tag word per value, with the payload riding a single 64-bit slot
(by-value structs are heap-boxed into it). 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.
Where the by-value heap box does and does not happen. In ARGUMENT position, widening a by-value member for a call that provably neither retains the payload nor suspends puts the copy in the caller's frame instead, so there is no allocation at all -- the same rule, and the same inference, that makes an
anywiden allocation-free. Every other position (a union bound to a local, returned, or held as a temporary) still boxes, and that box has no owner: one leak per widen. Pinned bytests/fixtures/union-widen-frame-boxunder LeakSanitizer; seedocs/reported/union-tagged-union-c-emission.mdfor what the remaining positions need.
Two member kinds do not ride the val slot as an integer, and both the inject
and the match binder account for it: a by-value aggregate is heap-boxed and
the slot holds a pointer, and a float rides as its IEEE-754 bit pattern rather
than a numeric conversion.
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) ...)
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.
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 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.
The following 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) |
| 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 |
(-> a b), (vec T), foralldefgadt, type refinement, union types interactionResult and Option types