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:

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
    }
}
Note: 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):

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.

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
FormTypeExample
Integer with nBigInt42n, 2n ** 64n
Float with nBigNum3.14n, 2.2e2n
Note: BigInt and BigNum are backed by the libbf arbitrary-precision library. Mixing BigInt with regular 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();
Note: The same => 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);
}
Tip: Any runtime error (type mismatch, undefined variable, etc.) can be caught with 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
}
Tip: Use 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
}
Note: 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:

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!
Note: 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
Note: Core module names after 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`