Union types
Zena has union types, written A | B. They carry four jobs:
- Nullability.
String | null, rather than a dedicatedString?form. Nullable references are just unions, so everything below applies to them. - Classes and interfaces.
Cat | DogorAnimal | null, narrowed withisor anullcheck. This is the ordinary case and has no restrictions at all. - Functions.
(() => i32) | ((x: i32) => i32)— and a union of function types is directly callable, not something you have to narrow first. - Closed sets of literals.
'aloof' | 'grumpy',1 | 2 | 3.
Scala 3 is the closest mainstream precedent. C#, Java, Kotlin, and Swift have no general union types and cover the same ground with nullable types and sealed hierarchies.
They are also restricted, in ways that surprise people arriving from TypeScript. Every restriction comes from one of two questions: can the union be stored? and can its members be told apart at runtime?
Constraints ​
Storage. A union has to compile to a single Wasm type that can hold any
member. An i32 is four bytes in a local; a reference is a GC pointer. There is
no Wasm type that is either, so i32 | String has nowhere to live. Every
language that offers it is boxing the primitive, which allocates.
Distinguishability. Narrowing an A | B means asking at runtime which one
you have. References carry their type and ref.test can ask. A raw i32 carries
nothing — no bit pattern distinguishes the integer 0 from null. And two Zena
types that erase to the same Wasm type can't be told apart either, even when
both are references.
Those two questions produce all of the rules below.
Illegal unions ​
The following are rejected, each for one of those two reasons:
| Union | Why |
|---|---|
i32 | null, boolean | String |
Different Wasm types |
i32 | f32 |
Different Wasm types |
i32 | u32, boolean | i32 |
Indistinguishable |
String | void |
void is not a type |
T | null where T is unbounded |
T could be instantiated with a primitive |
T | Foo where T is unbounded |
T could be instantiated with a primitive |
| Two distinct types over the same type | Indistinguishable |
| Two extension classes on the same type | Indistinguishable |
i32 | u32 is rejected for the second reason only. Both are i32 in Wasm, so
storage is fine, but nothing at runtime says whether the bits are signed, so the
union could not be narrowed.
Distinct types and extension classes fail the same test from the other side:
distinct type IdA = String and distinct type IdB = String are separate types
to the checker and both plain String at runtime, so IdA | IdB could never be
narrowed.
Allowed unions ​
Unions of reference types are unrestricted — classes, interfaces, arrays,
records, functions, and null, in any combination:
let a: String | null = null;
let b: Cat | Dog | null = null;
let r = if (b is Cat) b.meow() else 0; // narrowed by `is`
Primitives may union with primitives of the same base type, which is what
makes literal unions work — true | false is still one i32 at runtime, and no
narrowing is needed to represent it:
let mood: 'aloof' | 'grumpy' = 'aloof';
let flag: true | false = true;
let small: 1 | 2 | 3 = 1;
A union of function types is callable without narrowing, provided every member accepts the arguments given and their return types unify. Members may take fewer parameters than the call site passes, so the usual argument adaptation applies:
let f: (() => i32) | ((x: i32) => i32) = () => 1;
let r = f(5); // fine: the zero-arg member ignores the argument
Unions of inline tuples are how Map.get and Iterator.next return an optional
value without allocating.
→ Multi-value returns
Casts and type checks ​
Both reject a union target today, for different reasons.
is cannot test a union ​
let b = c is C | null; // error: Test each type separately.
A type test compiles to one ref.test. Testing against a union would test each
member in turn, putting a loop inside what reads as a single operation. The same
problem was raised for Dart
(dart-lang/language#2711).
Narrowing works one type at a time:
if (x is Cat) {
x.meow();
}
if (x == null) {
/* ... */
}
as cannot fill a union ​
let d = c as C | null; // error: Cast to each type separately.
This is unimplemented rather than deliberate. Some unions need a cast to fill
them:
1 | 2 | 3 accepts a literal at the assignment site and nothing else, so a
value computed at runtime can never enter it.
let a: 1 | 2 | 3 = 1; // fine
let n: i32 = readNumber();
let b: 1 | 2 | 3 = n; // error: expected 1 | 2 | 3, got i32
let c = n as 1 | 2 | 3; // error: Cast to each type separately
Casting should follow assignability, and for unions that should fall out of
widening and simplification rather than needing a rule of its own: 1 | 2 | 3
widens to i32, so n as 1 | 2 | 3 is legal for any n: i32.
→ Literal types
→ Working documents:
types.md,
non-nullable-refs.md