Language Guide
A complete reference for the ZayneScript language: syntax, semantics, and examples derived from the test suite and interpreter source.
Overview
ZayneScript is a dynamically-typed, interpreted scripting language compiled to an internal bytecode format and evaluated by a C runtime. Its syntax is intentionally close to JavaScript / C with a few purposeful differences:
-
The
asynckeyword appears after the parameter list. -
Block-scoped variables use
localinstead oflet. -
for/whileloop initialisers use:=(short assign), not keyword declarations. -
Switch expressions use
=>and are written in value position. -
Anonymous function expressions can use
=>for a single-expression body (see Expression body), distinct fromswitchsyntax.
Data Types
| Type | Examples | Notes |
|---|---|---|
null |
null |
Absence of a value |
bool |
true, false |
|
int |
0, -1, 42 |
Machine integer |
num |
3.14, 1e-9 |
64-bit double |
bigint |
100n, 2n**64n |
Arbitrary precision (libbf) |
str |
"hello", 'world' |
UTF-8 string |
array |
[1, 2, 3] |
Dynamic, heterogeneous |
object |
{ x: 1 } |
Key-value map |
function |
fn() {} |
First-class, closures |
class |
class Foo {} |
Class descriptor |
promise |
returned by async calls |
Async future value |
Variables
There are three declaration keywords with distinct scoping rules:
| Keyword | Scope | Mutable |
|---|---|---|
var |
Global (module level) | Yes |
const |
Global (module level) | No |
local |
Block / function | Yes |
Multiple declarations can be combined in one statement using commas:
var globalX = 10;
const PI = 3.14159;
const a = 1, b = 2, c = 3; // multiple consts
fn example() {
local x = 20;
local f = 6, g = 7; // multiple locals
if (true) {
local y = 30; // block-scoped; not visible outside this if
}
}
local variables declared inside
if, for, while, and other
blocks are restricted to that block's scope.
Operators
| Category | Operators |
|---|---|
| Arithmetic |
+ - * /
%
|
| Bitwise |
~ & | ^
<< >>
|
| Type introspection |
typeof (unary; see
below)
|
| Logical | && || ! |
| Comparison |
== != <
<= >
>=
|
| Augmented assignment |
+= -= *= /=
%= &= |=
^= <<=
>>=
|
| Increment / Decrement | ++ -- (prefix and postfix) |
| Spread | ... (in array and object literals) |
| Exponentiation (BigInt) | ** |
typeof
Unary typeof has the same precedence as other unary
operators. It evaluates its operand (for side effects)
and yields a string naming the runtime tag of the
resulting value — it does not short-circuit like
&& / ||.
Possible results include (non-exhaustive):
-
"Int","Number","BigInt","BigNum" "String","Bool","Null"-
"Array","Object","Function","Native Function","Class","Promise","Blob", … -
For class instances, the string is the
class name taken from the instance prototype (not the
literal
"Class"). This applies even inside base-class methods —typeof thisalways reflects the concrete runtime type of the receiver. -
typeof baseinside a subclass method yields"Class"—baseis a class descriptor, not an instance.
println(typeof 0); // Int
println(typeof 3.14); // Number
println(typeof 5n); // BigInt
println(typeof []); // Array
// Parentheses optional; operand is a unary expression
println(typeof (1 + 1));
Bitwise NOT (~)
Prefix ~ applies a two’s-complement bitwise inversion to
numeric values only. Non-numeric operands produce a
type error.
-
int— result isintwith all 32 bits ofCoerceToI32flipped. -
Number(double) — the value is coerced to a 64-bit integer (long) first, then inverted; the result is aNumber. -
BigInt/BigNum— inversion uses arbitrary-precision integer math (-n - 1in the libbf pipeline), preserving integer vs decimal precision of the operand where applicable.
println(~0); // -1
println(~4); // -5
Ternary Expressions
Two syntactic forms are supported:
// Standard C-style ternary
local result = condition ? "yes" : "no";
// Postfix if/else form
local result = "yes" if (condition) else "no";
Spread Operator
Use ... to expand an array or object in place:
local a = [1, 2, 3];
local b = [...a, 4, 5]; // [1, 2, 3, 4, 5]
local obj1 = { x: 1 };
local obj2 = { ...obj1, y: 2 }; // { x: 1, y: 2 }
BigInt & BigNum Literals
Append n to any numeric literal to create an
arbitrary-precision integer (BigInt) or an
arbitrary-precision decimal (BigNum — when the
literal contains a decimal point or exponent):
// BigInt — integer literals with n suffix
println(100n + 2n); // 102n
println(5n * 5n); // 25n
println(5n / 2n); // 2n (integer division truncates)
println(2n ** 64n); // 18446744073709551616n
// BigNum — float/scientific literals with n suffix (arbitrary-precision decimal)
println(10.56n); // 10.56n
println(2.2e2n); // 220n
// Mixed: shift operators accept one BigInt and one plain int
println(1 << 2n); // 4
// All four arithmetic operators work between BigInts
const huge = 2n ** 128n;
println(huge); // 340282366920938463463374607431768211456n
| Form | Type | Example |
|---|---|---|
Integer with n | BigInt | 42n, 2n ** 64n |
Float with n | BigNum | 3.14n, 2.2e2n |
int/num is supported in shift operators;
other mixed operations follow coercion rules defined by the runtime.
Functions & Closures
Functions are declared with fn. They are first-class
values and can be assigned to variables, passed as arguments, and
returned from other functions.
fn greet(name) {
println("Hello", name);
}
// Anonymous function expression
const square = fn(x) { return x * x; };
// Higher-order function
fn apply(f, x) { return f(x); }
println(apply(square, 5)); // 25
Expression body (=>)
In a function expression (after fn,
not a named fn name() declaration), you can write the body
as a single expression after =>. The expression is
evaluated and returned implicitly — no return keyword and
no braces. Optional async sits after the closing
) and before =>, matching named async
functions.
const double = fn(x) => x * 2;
println(double(21)); // 42
// Callbacks and higher-order helpers read cleanly
[1, 2, 3].each(fn(v, i) => println(v + i));
// Async expression body
const task = fn() async => await fetchData();
=> token is used in
switch expressions; there it pairs cases with
values, while here it introduces the body of an anonymous function.
Closures
Inner functions capture variables from their enclosing scope and can mutate them:
fn counter() {
local count = 0;
return fn() {
count += 1;
return count;
};
}
var c = counter();
println(c()); // 1
println(c()); // 2
println(c()); // 3
Multiple closures sharing state
fn makeCounters() {
local shared = 0;
local inc = fn() { shared += 1; };
local dec = fn() { shared -= 1; };
local get = fn() { return shared; };
return [inc, dec, get];
}
var counters = makeCounters();
var inc = counters[0];
var dec = counters[1];
var get = counters[2];
inc(); inc();
println(get()); // 2
dec();
println(get()); // 1
Immediately Invoked Function Expressions (IIFE)
Wrap an anonymous function in parentheses and call it immediately. Useful for creating a one-time private scope:
// No-argument IIFE
const x = (fn() { return 42; })();
println(x); // 42
// IIFE with arguments
const sum = (fn(a, b) { return a + b; })(10, 32);
println(sum); // 42
// IIFE for a private scope — result discarded
(fn() {
local secret = "hidden";
println("inside:", secret);
})();
Async / Await
The async keyword is placed after the
parameter list. Calling an async function returns a
Promise immediately. Use await inside
another async function to suspend until the awaited promise resolves.
fn fetchData() async {
return "payload";
}
fn main() async {
const data = await fetchData();
println("Got:", data); // Got: payload
return 1;
}
println(main()); // <Promise> — call is non-blocking
Anonymous async functions
const task = fn() async {
return "done";
};
Await chains
fn topLevel() async { return "Hello"; }
fn callMe() async {
println(await topLevel()); // Hello
println(await topLevel()); // Hello
return 1;
}
println(callMe()); // <Promise>
Promises
Every async function returns a Promise. The
.then(callback) method chains a reaction to the resolved
value; its return value becomes the next promise in the chain.
.error(callback) handles rejections.
fn awaitable() async { return "Hola!"; }
const v = awaitable()
.then(fn(v) {
println("resolved with:", v); // resolved with: Hola!
return 42;
})
.then(fn(v) {
println("chained value:", v); // chained value: 42
return "done";
})
.then(println); // done
println(v); // <Promise>
Error handling with .error()
fn risky() async {
return someUndefinedOp();
}
risky()
.then(fn(v) { println("success:", v); })
.error(fn(e) { println("caught:", e); });
if / else
if (x > 0) {
println("positive");
} else {
println("non-positive");
}
Optional initialiser (:=)
The if condition supports an initialiser separated by
;. The declared variable is scoped to the if
block:
if (val := computeValue(); val > 0) {
println("got", val);
}
for Loop
The for initialiser uses :=; the loop
variable is scoped to the loop:
for (i := 0; i < 10; i++) {
println(i);
}
// Floating-point step
for (x := 0.0; x < 1.0; x += 0.1) {
println(x);
}
while Loop
// Standard while
while (condition) {
// ...
}
// With initialiser — variable scoped to loop
while (i := 0; i < 10) {
println(i);
i++;
}
// With initialiser + mutator (three-part form)
while (i := 0; i < 10; i++) {
println(i);
}
do-while Loop
var n = 0;
do {
println(n++);
} while (n < 5);
switch
Two forms are available: statement and expression.
Statement form
Uses case with : and block bodies.
Comma-separated values match multiple literals in one case:
switch (num) {
case 0, 1: {
println("matched 0 or 1");
}
case 2, 3: {
println("matched 2 or 3");
}
default: {
println("no match");
}
}
Expression form
Placed after the value being tested; uses => and
produces a value. No break needed:
const label = 100 switch {
case 10 => "Ten"
case 20 => "Twenty"
case 10, 30, 100 => "A Hundred"
default => "Unknown"
};
println(label); // A Hundred
try / catch
Both try and catch bodies must be block
statements:
try {
local x = riskyOperation();
println("success:", x);
} catch (e) {
println("Error:", e);
}
try/catch.
raise
The raise statement throws a runtime error that can be
caught by an enclosing try/catch block. Any value can be
raised — it becomes the error value bound to the catch parameter.
// Raise a string error
raise "Something went wrong";
// Raise inside a function
fn divide(a, b) {
if (b == 0) {
raise "Division by zero";
}
return a / b;
}
try {
println(divide(10, 0));
} catch (e) {
println("Caught:", e); // Caught: Division by zero
}
// Raise an object for structured errors
try {
raise { code: 404, message: "Not found" };
} catch (e) {
println(e.code, e.message); // 404 Not found
}
raise to signal unrecoverable
conditions inside library functions; callers can catch them with
try/catch.
assert
The assert statement evaluates a condition and throws a
runtime error when it is falsy. An optional message string follows the
condition after a comma. Assertions are always evaluated (there is no
debug-only mode) and can be caught by try/catch.
// Basic assertion — throws if condition is false
assert 2 + 2 == 4;
// With a descriptive message (shown when the assertion fails)
assert x > 0, "x must be positive";
// Assertions in functions work like built-in contracts
fn factorial(n) {
assert n >= 0, "factorial requires a non-negative integer";
if (n <= 1) { return 1; }
return n * factorial(n - 1);
}
println(factorial(5)); // 120
// Failed assertions can be caught
try {
assert false, "always fails";
} catch (e) {
println("Caught:", e); // Caught: always fails
}
assert is the standard way to
write self-documenting invariants and is used throughout the test
suite to verify correct behaviour.
break & continue
break exits the nearest enclosing loop or switch.
continue skips the rest of the current loop iteration.
Both correctly unwind through nested try/catch blocks:
for (i := 0; i < 10; i++) {
if (i == 5) break;
if (i % 2 == 0) continue;
println(i); // 1 3
}
Classes
Classes support single inheritance. The superclass is listed in
parentheses after the class name. The constructor is named
init.
class Animal {
fn speak() {
println("...");
}
}
class Dog (Animal) {
fn init(name) {
this.name = name;
}
fn speak() {
println(this.name, "says Woof!");
}
}
const d = new Dog("Buddy");
d.speak(); // Buddy says Woof!
A subclass automatically inherits all methods from the parent.
Override a method by re-declaring it in the subclass. Access the
instance with this.
Inheritance & base
Inside any subclass method, base is a special reference
to the parent class descriptor. It has two uses:
-
Calling the parent constructor —
base(this, arg1, arg2, …)invokes the parentinitmethod. The first argument must always be the current instance (this); subsequent arguments are forwarded to the parentinitparameter list. -
Calling a parent method explicitly —
base.methodName(this, arg1, …)calls the parent class's version of a method, bypassing any override in the subclass. Again,thisis passed as the first argument.
class BaseClass {
fn init(a) {
this.Type = typeof(this); // runtime class name of the instance
println("From BaseClass!", this.Type);
}
fn greet() {
println("greet::From BaseClass!");
}
}
class DerivedClass (BaseClass) {
fn init() {
// Invoke the parent constructor — this is passed as the receiver,
// 2 is forwarded as the `a` parameter of BaseClass.init.
base(this, 2);
// typeof base → "Class"; typeof this → "DerivedClass"
println(typeof base, typeof this);
println("From DerivedClass!");
// Call the parent greet explicitly (prints BaseClass version),
// then call the overridden greet on this (prints DerivedClass version).
println(base.greet(this), this.greet());
}
fn greet() {
println("greet::From DerivedClass!");
}
}
new DerivedClass();
// From BaseClass! DerivedClass
// Class DerivedClass
// From DerivedClass!
// greet::From BaseClass! greet::From DerivedClass!
typeof base yields
"Class" — the parent is a class descriptor value.
typeof this inside any class method yields the
runtime class name of the instance (e.g.
"DerivedClass"), even when the method is defined on
the base class. This means you always see the concrete type, not
the declaring class.
Static Members
Use static fn for static methods and
static name = value; for static properties:
class MathUtils {
static PI = 3.14159;
static fn square(x) {
return x * x;
}
}
println(MathUtils.PI); // 3.14159
println(MathUtils.square(4)); // 16
Arrays
Arrays are dynamic and heterogeneous. They use zero-based integer indexing:
local list = [1, 2, 3];
list.push(4); // append
list.pop(); // remove last → returns it
println(list.length()); // 3
println(list[0]); // 1
// Spread
local more = [...list, 10, 20];
Iteration with each
each(callback) calls
callback(value, index)
for every item and returns a new array of the return values:
const nums = [1, 2, 3, 4, 5];
const doubled = nums.each(fn(v, i) { return v * 2; });
println(doubled); // [2, 4, 6, 8, 10]
// println receives each argument pair: element, then index
nums.each(println);
// 1 0
// 2 1
// 3 2 ...
Filtering with keep
keep(callback) returns only the elements for which
callback(value, index) returns truthy:
const odds = nums.keep(fn(v, i) { return v % 2 != 0; });
println(odds); // [1, 3, 5]
Unpacking arrays
ZayneScript does not support JavaScript-style
bracket destructuring on the left-hand side of
var, const, or local
(forms such as var [a, b] = arr; are a parse error).
Declarations must begin with an identifier; use indexing or bind
names from separate expressions instead.
fn rgb() { return [255, 128, 0]; }
var c = rgb();
var r = c[0];
var g = c[1];
var b = c[2];
println(r, g, b); // 255 128 0
// Or multiple names when you have one expression per slot:
var pair = [1, [2, 3, 4]];
var head = pair[0];
var tail = pair[1];
println(head); // 1
println(tail[0]); // 2
In a for header, the initializer can
use := to assign several loop variables from a
comma-separated list of expressions (see that section). That is not
the same as unpacking a single array value into multiple bindings.
Objects
Objects are key-value maps. Access properties with dot notation or bracket notation:
local person = {
name: "Alice",
age: 30
};
println(person.name); // Alice
println(person["age"]); // 30
// Shorthand: variable name == key name
local x = 10;
local obj = { x }; // equivalent to { x: x }
// Spread
local extended = { ...person, city: "NYC" };
Imports
Three import forms are supported:
Named imports
import { println, scan, parseNum, File } from "core:io";
import { sqrt, pi, pow } from "core:math";
import { getUser, getCwd, args } from "core:os";
import { Array } from "core:array";
import { Date } from "core:date";
import { Promise } from "core:promise";
import { Object } from "core:object";
import { nfc, toLower, len } from "core:utf8";
Wildcard import (module bound to its name)
import "core:math"; // accessible as `math`
println(math.sqrt(16)); // 4
println(math.pi); // 3.141592...
Relative file imports
import "./utils"; // runs ./utils.zs
import { helper } from "./helpers/math"; // named import from file
core: are
case-sensitive and match the loader (core:date,
core:array, …). The .zs extension is appended
automatically for file imports. Each module is executed once and cached;
circular imports are detected and produce a runtime error.
User Libraries (lib:)
User-authored .zs files placed in the
lib/ directory are importable via the
lib: prefix. Subdirectories are supported using
/. The repository ships lib/http.zs, which
re-exports request from core:mongoose plus
frozen StatusCode and Method lookup objects
built with Object.freeze from core:object.
It also ships lib/argparser.zs, a parser for the token
array returned by args() from core:os (see
Standard Library → lib:argparser).
import { request, Method, StatusCode } from "lib:http";
import { createArgParser } from "lib:argparser";
import "lib:nested/mod"; // lib/nested/mod.zs → bound as `mod`