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:
| Group | Expands to |
|---|---|
Int | Int8, Int16, Int32, Int64, Int256, UInt8, UInt16, UInt32, UInt64, UInt256 |
Float | Float16, Float32, Float64 |
Number | All 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
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
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
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
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
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
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
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
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
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.
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:
- You can't assign a union to a concrete variable. If
aisInt | Floatand you haven't narrowed it (or you're past a branch merge), writingb: Int32 = ais a compile error. The compiler doesn't know which typeaactually holds. - You can't cast a union. Writing
Int64(a)whenaisInt | Floatwon't work. Narrow it to a specific type first. - Mixed arithmetic after narrowing is rejected. If you narrow to
Float32and then add an integer literal, the compiler treats it asFloat32 + Int32and refuses. Use a matching literal (2.0instead of2). - Control flow resets narrowing. After if/else blocks and while loops, a union variable always reverts to its full declared type. There's no way to "remember" a narrowing across a branch boundary.
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.