Threadon / Unions

Unions

one variable, several possible types

What is a union type

Sometimes you want a variable that can hold an integer on Monday and a float on Tuesday. That's what union types are for. A union says "this value is one of these concrete types at any given moment, but which one depends on what you last stuck in it."

Write a union with the pipe character (|) between types:

x: Int32 | Float32 = 5

Right now x holds an Int32. That's not a guess, it's a fact: the compiler knows because you assigned an integer literal to it. This matters later.

Group types: Int, Float, Number

Writing Int32 | Int64 | Float32 | Float64 | ... would be tedious and honestly kind of dumb. Threadon provides shorthand group names that expand automatically:

GroupExpands to
IntInt8, Int16, Int32, Int64, Int256, UInt8, UInt16, UInt32, UInt64, UInt256
FloatFloat16, Float32, Float64
NumberAll integer and float types

So when you write:

x: Int | Float = 42

the compiler sees Union[Int8|Int16|Int32|Int64|Int256|UInt8|UInt16|UInt32|UInt64|UInt256|Float16|Float32|Float64]. That's a mouthful, but nobody has to type it. The group just blows up into every concrete type it contains, and the union holds all of them.

Groups work in function signatures too:

def double_it(n: Int | Float) -> Int | Float
    return n * 2

That function accepts any integer or any float. More on functions with unions in a bit.

Narrowing: the compiler keeps track

Here's the part that might surprise you. When you assign a concrete value to a union variable, the compiler narrows its type to that concrete type in the current scope. It remembers what you just did.

def main() -> Int32
    a: Int | Float = 5
    # here a is narrowed to Int32
    b: Int32 = a   # this compiles fine
    print(a)
    return 0
5

After a: Int | Float = 5, the compiler knows a is an Int32 right now. You can assign it to a concrete Int32 variable without complaint. This is narrowing — the union collapses to a single type based on the last assignment.

You can reassign to a different member type:

def main() -> Int32
    a: Int | Float = 5
    a = 3.5       # now a is Float32
    b: Float32 = a
    print(b)
    return 0
3.500000

Each assignment reshapes what the compiler believes about the variable. It's not some runtime thing where it checks a tag — the narrowing happens entirely at compile time.

Arithmetic on union values

You can do math on a narrowed union variable. The compiler checks whether the operation makes sense for whatever type the variable currently holds.

def main() -> Int32
    a: Int | Float = 5
    print(a)
    a = a * 2
    print(a)
    a = 9.5
    print(a)
    return 0
5 10 9.500000

After a = 5, the variable is narrowed to Int32. So a * 2 is Int32 * Int32, which produces an Int32. Fine. Then you reassign a = 9.5, and it becomes a Float32. The print knows to format it as a float.

But here's the gotcha. If you narrow to a float and then try to add an integer literal, the compiler will yell at you:

def main() -> Int32
    a: Int | Float = 5
    a = 3.5
    c: Int | Float = a + 2
    return 0
Error: Type mismatch in arithmetic: Float32 vs Int32

Why? Because after a = 3.5, the compiler sees a as Float32. And Float32 + Int32 is not allowed — Threadon doesn't auto-promote. You'd need to write a + 2.0 instead. The strict type checking applies everywhere, even inside arithmetic on union variables.

Control flow: types revert

Something important happens when control flow branches merge back together. Any narrowing that happened inside a branch gets thrown away. The variable reverts to its declared type.

def main() -> Int32
    x: Int | Float = 5
    if x > 3:
        x = 2.5     # x narrowed to Float32 here
    else:
        x = 7       # x narrowed to Int32 here
    # x is back to Int | Float here
    print(x)
    return 0
2.500000

After the if/else, x is Int | Float again. The compiler doesn't know which branch ran at compile time, so it can't assume a specific type. This means:

def main() -> Int32
    x: Int | Float = 5
    if x > 3:
        x = 2.5
    else:
        x = 7
    b: Int32 = x    # compile error: x might be a float
    return 0
Error: Variable 'b' expects type Int32, got Union[Float32|Int32]

Assigning back to the union type works, though:

def main() -> Int32
    x: Int | Float = 5
    if x > 3:
        x = 2.5
    else:
        x = 7
    b: Int | Float = x   # fine
    return 0

The same reversion happens after while loops. Any narrowing inside the loop body doesn't leak out.

def main() -> Int32
    x: Int | Float = 5
    while x > 3:
        x = 2.5     # Float32 inside the loop
    # back to Int | Float here
    b: Int32 = x    # compile error again
    return 0
Error: Variable 'b' expects type Int32, got Union[Float32|Int32]

It's a bit annoying if you're coming from languages where narrowing "sticks", but it keeps things safe. The compiler won't guess what happened at runtime.

A while loop that stays within the same type family works perfectly well though:

def main() -> Int32
    y: Int | Float = 1
    while y < 3:
        y = y + 1
    print(y)
    return 0
3

Here y starts as Int32 (narrowed from the literal 1). Each iteration does Int32 + Int32, keeps it as Int32, reassigns it — narrowing stays consistent. No type mix-up, no errors.

Functions with union parameters

A function can accept a union type as a parameter. The guarantee: every operation inside the function must be valid for every type in the union.

def twice(x: Int | Float) -> Int | Float
    return x * 2

def main() -> Int32
    print(twice(4.07))
    print(twice(3))
    return 0
8.140000 6

Both Int and Float types support multiplication, so the compiler lets it slide. The function doesn't care what specific type it receives — as long as the operation exists for all possibilities, it's good.

Inside the function, the parameter starts as the full union type. Narrowing still applies: if you assign it to a concrete variable, you've narrowed it.

Returning from a union function also follows strict rules. You can return any member type or a compatible subset union. The compiler wraps the return value automatically to match the declared return type.

Note: Union types are not overloads. There's no twice(Int32) and twice(Float32) pair hidden somewhere. There's one function body, and it has to work for all the types listed in the union.

What you can't do

Unions are useful but they come with friction. Here's what's off-limits:

These restrictions feel aggressive coming from dynamically typed languages, but they exist because Threadon checks everything at compile time. No runtime surprises.

Under the hood (if you're curious)

You don't need to know this to use unions, but it's interesting. Under the surface, a union value is a small struct: an i8 tag (which records which type is active) followed by storage for the actual value. When you do arithmetic on a union, the compiler generates a runtime switch on the tag, performs the operation for every possible type combination, and merges the results back together.

That means union operations are slightly more expensive than concrete operations — there's a branch at runtime. For most programs this doesn't matter at all. But if you're writing something where every cycle counts, concrete types are faster.