Type Testing and Narrowing
Zena is a statically typed language with a sound type system targeting
WebAssembly GC. There is no any type, no implicit type coercion, and no
unchecked downcasts.
To support dynamic polymorphism, untagged union types, and nullable references while maintaining soundness, Zena provides three tightly integrated features:
- The
isoperator: Evaluates at runtime whether an expression conforms to a specific type, returning aboolean. - The
asoperator: Performs checked downcasts, explicit numeric conversions, zero-cost distinct type casts, and interface adaptations. - Control-flow narrowing: Statically refines variable types across
branches, logical conditions (
&&,||), loops, and early-exit paths without requiring boilerplate manual casts.
The is operator ​
The is operator performs a runtime type check on an expression.
Syntax and return value ​
expression is TargetType
The is operator always returns a primitive boolean (true or false). It
never throws an exception or causes a runtime trap.
Class hierarchy checks ​
When testing class instances, is verifies whether the instance is of the
specified class or any of its subclasses. This compiles directly to the
WebAssembly GC ref.test instruction:
class Animal {}
class Dog extends Animal {
bark(): String { return 'woof'; }
}
class Cat extends Animal {
meow(): String { return 'meow'; }
}
let pet: Animal = new Dog();
pet is Dog; // true
pet is Cat; // false
pet is Animal; // true
Null handling ​
null is not an instance of any non-nullable class or interface. Testing null
against a non-nullable type always yields false:
let maybePet: Animal? = null;
maybePet is Animal; // false
maybePet is Dog; // false
Testing against a nullable type is also supported:
maybePet is Animal?; // true
Reified generics at runtime ​
Unlike languages where generic type arguments are erased at runtime (such as TypeScript or Java), Zena compiles to WebAssembly GC with reified generics. Each generic specialization possesses its own distinct runtime type descriptor.
This means generic type arguments are fully testable with is:
class Box<T> {
value: T;
new(this.value);
}
let a: anyref = new Box<i32>(42);
let b: anyref = new Box<String>('hello');
a is Box<i32>; // true
a is Box<String>; // false
b is Box<String>; // true
b is Box<i32>; // false
Testing through interfaces ​
In Zena, an object held as an interface is represented as a fat pointer containing the underlying instance paired with the interface vtable.
The is operator inspects the underlying concrete instance through the
interface fat pointer:
interface Shape {
area(): f64;
}
class Circle implements Shape {
radius: f64;
new(this.radius);
area(): f64 { return 3.14159 * this.radius * this.radius; }
}
class Square implements Shape {
side: f64;
new(this.side);
area(): f64 { return this.side * this.side; }
}
let shape: Shape = new Circle(5.0);
shape is Circle; // true
shape is Square; // false
Generic specializations also survive through interfaces: testing shape is Cell<i32> accurately discriminates between Cell<i32> and Cell<String>.
Discriminating union types ​
The is operator is the primary mechanism for inspecting and discriminating
untagged union types:
class Success {
data: String;
new(this.data);
}
class Failure {
error: String;
new(this.error);
}
type Result = Success | Failure;
function handle(r: Result): void {
if (r is Success) {
println('Success: ' + r.data);
} else {
println('Error: ' + r.error);
}
}
Primitives and anyref ​
Zena does not perform implicit boxing. Primitive values (i32, i64, f32,
f64, boolean) are unboxed scalars and cannot be directly assigned to
anyref.
To test a primitive value behind a generic reference, it must be explicitly
boxed using Box<T> from zena:core:
import { Box } from 'zena:core';
let boxed: anyref = new Box<i32>(100);
boxed is Box<i32>; // true
boxed is Box<f64>; // false
Direct primitive type tests (e.g. x is i32) are evaluated statically by the
compiler when x is already a primitive.
The as operator ​
The as operator performs explicit type conversions, downcasts, and static type
assertions.
Syntax ​
expression as TargetType
Zena has no unchecked casts. Every cast with as is either validated statically
at compile time or compiled into a checked runtime operation that will trap
if the cast is invalid.
Reference downcasting and runtime traps ​
Downcasting converts a reference from a broader type (such as a superclass,
interface, or anyref) to a more specific subtype:
class Animal {
name: String;
new(this.name);
}
class Dog extends Animal {
bark(): void { println('woof'); }
new(name: String) : super(name) {}
}
let a: Animal = new Dog('Rex');
let d = a as Dog; // Compiles to WebAssembly ref.cast
d.bark();
Runtime traps on failed downcasts
If the runtime value does not match the target type, the WebAssembly ref.cast
instruction immediately raises an unrecoverable runtime trap:
let a: Animal = new Animal('Generic');
let d = a as Dog; // Traps: RuntimeError: illegal cast
Because Zena features control-flow narrowing, you rarely need explicit
downcasts after a runtime type check. Testing if (a is Dog) or
pattern-matching in match (a) automatically narrows a to Dog across the
guarded scope without writing a cast. Writing a as Dog after an is Dog check
is redundant and can trigger an unnecessary-cast compiler warning.
Explicit downcasts are primarily useful when control-flow narrowing cannot see the relationship between types or cannot prove safety:
- Heterogeneous storage and registries: Retrieving a value from an
anyrefcollection, cache, or event bus where an external key guarantees the concrete type (let handler = registry.get(id) as ClickHandler). - Un-narrowable mutable paths: Reading from a mutable (
var) field or complex getter where the compiler cannot guarantee the field hasn't mutated since a previous check. - Correlated types and external invariants: When two separate values have an application-level relationship that is not directly modeled as a discriminated union (for example, receiving an untyped message payload whose schema is governed by a separate protocol header).
Compile-time upcasting ​
Upcasting moves from a more specific type to a broader type (such as casting a subclass to its superclass, or to an interface):
let d = new Dog('Rex');
let a = d as Animal; // Zero cost: upcast validated at compile time
Because upcasts are guaranteed safe by the type hierarchy, the compiler elides them at runtime, incurring zero performance penalty.
Numeric conversions ​
Zena enforces strict type isolation between numeric types. There is no implicit
widening or narrowing between different integer or float types (with the sole
exception of i32 + f32 -> f32 in binary arithmetic).
All numeric conversions require explicit as casts:
| Conversion | Target Type | Behavior | Example |
|---|---|---|---|
i32 to i64 |
Widening integer | Sign-extended to 64 bits | 42 as i64 |
i64 to i32 |
Narrowing integer | Truncated to low 32 bits | x as i32 |
i32 to f32 / f64 |
Int to float | Numerical conversion | 10 as f64 |
f64 to i32 / i64 |
Float to int | Truncates toward zero | 3.99 as i32 (yields 3) |
i32 <-> u32 |
Signed <-> Unsigned | Bit reinterpretation (zero cost) | -1 as u32 (yields 4294967295) |
Integer to u8 / u16 |
Byte / Short | Truncates to low bits, zero-extended | 300 as u8 (yields 44) |
Integer to i8 / i16 |
Signed Byte / Short | Truncates to low bits, sign-extended | 200 as i8 (yields -56) |
Distinct types and opaque types ​
Distinct types (distinct type ID = i32;) create nominal type wrappers over
existing underlying types:
distinct type UserID = i32;
let id: UserID = 101 as UserID; // Wrap: zero-cost compile-time check
let raw: i32 = id as i32; // Unwrap: zero-cost compile-time check
Casting between a distinct type and its underlying representation is checked at compile time and elided at runtime.
Opaque types (distinct type OpaqueID = i32; inside an export boundary)
enforce encapsulation: outside the defining module, an opaque type cannot be
forged by casting from the primitive type.
Contextual typing via as ​
When casting an expression literal to a reference type, the as operator
provides a contextual type to the operand:
let names = [] as Array<String>; // Informs [] that its element type is String
Without the cast, an empty array literal [] has no inferable element type.
In contrast, casting a primitive value is a conversion rather than contextual
typing: -1 as u32 preserves -1 as an i32 scalar and reinterprets its bit
pattern as an unsigned 32-bit integer.
Disallowed casts and compiler diagnostics ​
The compiler rejects unsafe or meaningless casts at compile time:
-
Primitive to Reference (and vice versa):
zenalet s = 123 as String; // Compile error: Cannot cast primitive type 'i32' to reference type 'String'. let n = obj as i32; // Compile error: Cannot cast reference type to primitive type.To convert a number to a string, use string interpolation (
'${x}') or a formatting function. To read a primitive from a heap object, access its unboxed field (e.g.(obj as Box<i32>).value). -
Union Types as Cast Targets:
zenalet pet = animal as (Dog | Cat); // Compile error: Cannot use union type as cast target.A cast must name a concrete, single type.
-
Unnecessary Cast Warnings: When the
--warn-unnecessary-castscompiler flag is enabled, casting an expression to a type it already possesses emits a compiler warning:zenalet x: i32 = 10; let y = x as i32; // Warning: Unnecessary cast: expression is already of type 'i32'.
Control-flow narrowing ​
Control-flow narrowing (also known as flow-sensitive typing) is the automatic refinement of an expression's static type based on runtime guards and code execution paths.
Rather than forcing developers to manually cast variables with as, the Zena
compiler tracks conditions across the control-flow graph (CFG) and automatically
updates variable types in branches where those conditions hold true.
Equality and literal type discrimination ​
In Zena, null is not an ad-hoc language quirk—it is a literal type
inhabited solely by the single value null. A nullable type String? is simply
an untagged union: String | null.
Because null has only one possible value, it is a unit type. For any unit
type, testing value equality (==) is logically identical to testing type
membership:
function printLength(text: String?): void {
if (text != null) {
// 'null' is subtracted from String | null; 'text' is narrowed to String
println(text.length);
} else {
// 'text' is narrowed to null
}
}
The compiler recognizes all standard equality comparisons:
x !== nullandx != null(narrows to non-null in thetruebranch,nullin theelsebranch).x === nullandx == null(narrows tonullin thetruebranch, non-null in theelsebranch).- Reversed operand order (
null !== x,null == x).
Unit types and sealed cases ​
The equivalence of equality and type membership extends directly to unit variants in sealed class hierarchies (cases declared without fields):
sealed class Option {
case Some(value: i32)
case None
}
Unit variants like None have no state and are allocated as runtime singletons.
Because only one instance exists:
opt is Nonetests whetheroptis an instance of subclassNone.opt == Nonetests whetheroptis reference-equal to the singletonNone.
Both evaluate to the exact same result. The compiler narrows opt to None in
the true branch, and eliminates None from the union in the else branch.
Discriminating literal unions ​
Equality narrowing applies to any union of literal types. Testing against a specific value eliminates all alternative literal values:
type Mode = 'read' | 'write' | 'append';
function configure(mode: Mode): void {
if (mode == 'read') {
// 'mode' is narrowed to 'read'
setReadOnly();
} else {
// 'read' is eliminated; 'mode' is narrowed to 'write' | 'append'
setWritable();
}
}
Type discrimination with is ​
When a variable has a union type of classes or interfaces, testing with is
narrows the variable to the tested type in the true branch, and eliminates
that type from the union in the else branch:
class Cat {
meow(): String { return 'meow'; }
}
class Dog {
bark(): String { return 'woof'; }
}
function speak(pet: Cat | Dog): String {
if (pet is Cat) {
// 'pet' is narrowed to Cat
return pet.meow();
} else {
// Cat is eliminated; 'pet' is narrowed to Dog
return pet.bark();
}
}
If a union contains three or more types (Cat | Dog | Bird), checking pet is Cat leaves pet as Dog | Bird in the else branch.
Pattern matching with match() ​
Zena's match expression provides exhaustive pattern matching. Each case arm
establishes a narrowed flow path for the discriminant variable (as well as
immutable member paths):
sealed class Shape {
case Circle(radius: f64)
case Rect(width: f64, height: f64)
}
function calculateArea(s: Shape): f64 {
return match (s) {
case Circle {radius}: {
// 's' is narrowed to Circle; 'radius' is extracted
3.14159 * radius * radius
}
case Rect {width, height}: {
// 's' is narrowed to Rect; 'width' and 'height' are extracted
width * height
}
};
}
Narrowing in match expressions also applies to:
- Untagged unions: Matching
case Cat:inmatch (pet: Cat | Dog)narrowspettoCat. - Unit variants: Matching
case None:orcase Red:matches the singleton variant. - Immutable member paths:
match (holder.shape)narrowsholder.shapeinside each case arm.
Pattern narrowing with if let and while let ​
Zena supports refutable pattern matching in conditional statements via if (let pattern = expr) and while (let pattern = expr).
The compiler analyzes the destructuring pattern and narrows the bound variables to the specific types of the matched variant.
Tagged inline tuple unions (Map.get and Iterator.next) ​
Standard library operations such as Map.get(key) and Iterator.next() return
unboxed, zero-cost inline tuple unions:
Map.get(key)returnsinline (true, V) | inline (false, _)-Iterator.next()returnsinline (true, T) | inline (false, _)
When destructuring these unions with a pattern containing a literal true tag,
the compiler's narrowTypeByPattern analysis filters out the (false, _)
variant. As a result, the payload variable in position 1 is automatically
narrowed to the actual value type (excluding the hole _):
import { Map } from 'zena:collections';
let users = new Map<String, User>();
// Map.get returns inline (true, User) | inline (false, _)
if (let (true, user) = users.get('alice')) {
// Matching literal 'true' eliminates the (false, _) variant.
// 'user' is narrowed to User (not User | _).
println('Found user: ' + user.name);
} else {
println('User not found');
}
The same pattern narrowing powers zero-allocation stream and generator iteration
using while (let ...):
function drainIterator<T>(iter: Iterator<T>): void {
// Iterator.next returns inline (true, T) | inline (false, _)
while (let (true, item) = iter.next()) {
// 'item' is narrowed to T for each iteration
processItem(item);
}
// Loop exits when iter.next() returns (false, _)
}
This pattern enables expressive, zero-allocation optional and fallible returns
without requiring heap-allocated Option or Result wrapper objects.
Class destructuring with if let ​
if let similarly discriminates sealed class variants:
sealed class Option<T> {
case Some(value: T)
case None
}
function inspect(opt: Option<i32>): void {
if (let Some {value} = opt) {
// 'value' is bound with type i32
println('Value: ' + value);
}
}
Logical operators and compound conditions ​
The type checker analyzes logical expressions from left to right:
Logical AND (&&) ​
In an && chain, each condition narrows the type for subsequent conditions in
the same expression and for the guarded body:
class Inner(value: String)
class Outer(inner: Inner?)
function getLength(outer: Outer?): i32 {
// 'outer' is narrowed first; then 'outer.inner' can be checked
if (outer != null && outer.inner != null) {
return outer.inner.value.length;
}
return 0;
}
Logical OR (||) ​
When alternatives are joined with ||, the types from all branches are combined
into a narrowed union:
function inspect(pet: Cat | Dog | Bird): void {
if (pet is Cat || pet is Dog) {
// 'pet' is narrowed to Cat | Dog
handleMammal(pet);
} else {
// Both Cat and Dog eliminated; 'pet' is narrowed to Bird
handleBird(pet);
}
}
Conditional (if) expressions ​
Narrowing applies identically inside expressions:
let length = if (str != null) str.length else 0;
Loops and flow inversion ​
Narrowing is computed on the control-flow graph (CFG), enabling precise tracking across loops:
Loop condition guards ​
Variables in while loop conditions are narrowed throughout the loop body:
function processChain(start: Node?): i32 {
var current = start;
var sum = 0;
while (current != null) {
sum += current.value; // 'current' is narrowed to Node
current = current.next;
}
return sum;
}
Post-loop condition inversion ​
When a loop terminates, the compiler knows the loop condition must have
evaluated to false. If the loop condition checked for null or equality, that
inversion holds after the loop:
function waitForNode(): Node {
var node: Node? = null;
while (node == null) {
node = fetchNext();
}
// The loop only exits when node != null:
return node; // 'node' is narrowed to Node!
}
Early exit and never-returning calls ​
When a branch terminates execution through return, throw, or a call to a
function that returns the bottom type never (such as panic() or an
error-throwing helper), execution cannot continue along that path.
The compiler applies the inverted condition to all subsequent code in the outer scope:
function fail(message: String): never {
throw new Error(message);
}
function processUser(user: User?): String {
if (user == null) {
fail('User must not be null');
}
// The null path never returns, so 'user' is narrowed to User here:
return user.name;
}
This pattern eliminates deep if-else nesting in favor of early guard clauses.
Path narrowing: variables and members ​
Zena narrows both bare variable identifiers and paths through immutable data structures:
Immutable variables (let) ​
Variables declared with let are immutable. Once narrowed, their narrowed type
persists for the entire scope of the guard.
Mutable variables (var) and reassignment invalidation ​
Variables declared with var can be modified. When a var binding is
reassigned:
- If the newly assigned value satisfies the narrowed type, the narrowing survives.
- If the newly assigned value does not satisfy the narrowed type (e.g. assigning a nullable value back to a narrowed non-null variable), the narrowing is invalidated and the variable widens back to its declared type:
function update(box: Box?): i32 {
var b = box;
if (b != null) {
println(b.value); // OK: b is narrowed to Box
b = fetchMaybeBox(); // May return null
// println(b.value); // Compile error: Object may be null!
b = new Box(42); // Always non-null
println(b.value); // OK: narrowed back to Box
}
return 0;
}
Immutable paths (let fields, records, tuples) ​
Narrowing safely extends to member accesses when every segment of the path is immutable:
letclass fields: Cannot be reassigned after construction.- Record fields: Always immutable in Zena (
{x: 10, y: 20}). - Tuple elements: Always immutable in Zena (
(node, 42)).
class Container {
let inner: Node?;
new(this.inner);
}
function read(c: Container, r: {item: Node?}, t: (Node?, i32)): i32 {
var sum = 0;
if (c.inner != null) {
sum += c.inner.value; // c.inner narrowed to Node
}
if (r.item != null) {
sum += r.item.value; // r.item narrowed to Node
}
if (t[0] != null) {
sum += t[0].value; // t[0] narrowed to Node
}
return sum;
}
Why mutable fields (var) cannot be narrowed ​
Fields declared with var cannot be narrowed across control flow because
another alias, a concurrent task, or a helper method could modify the field
between the check and its usage:
class MutableContainer {
var inner: Node? = null;
new();
}
function process(c: MutableContainer): i32 {
if (c.inner != null) {
// Error: Cannot narrow mutable field 'inner'.
// Another reference could set c.inner = null here.
return c.inner.value;
}
return 0;
}
Idiom: Local binding: To safely narrow a mutable field, copy it into a local
let binding first:
function process(c: MutableContainer): i32 {
let inner = c.inner;
if (inner != null) {
return inner.value; // OK: local 'inner' is immutable and narrowed
}
return 0;
}