Syntax Basics

Comments

# line comment

/* block
   comment */

// is integer division, not a comment.

Doc comments are retained for reflection and documentation tools. Use ## for line docblocks or /** ... */ for block docblocks immediately before the declaration they describe:

## Returns the display name.
func name(): string {
    return "Ada";
}

/**
 * Handles user routes.
 */
class UserController {}

Variables And Constants

string name = "Ada";
int count = 3;
let inferred = 42;
const VERSION = "0.1.0";

Variables are mutable. Constants cannot be reassigned.

Use let when the type is obvious from the initializer. Use an explicit type when it documents an API boundary, narrows a nullable value, or prevents an accidental wider type.

let retries = 3;
dict<string, any> payload = json.parse(text);

The any type

any is Geblang's explicit opt-in for dynamic values. It is useful at boundaries where the concrete shape is not known until runtime: decoded JSON, HTTP request payloads, framework metadata, extension values, and generic dictionary contents.

any value = "queued";
value = 42;
value = {"status": "done"};

The binding is still statically typed: its static type is any. That means the analyzer allows assignments from different runtime types, but it also means you must narrow or cast before using type-specific behaviour:

dict<string, any> payload = json.parse(text);
string name = payload["name"] as string;
int count = payload["count"] as int;

Prefer precise types for application logic and public APIs. Use any only where a value is intentionally dynamic or where a generic container needs to hold mixed values.

Strings

Double-quoted strings process escape sequences and support interpolation:

string msg = "Hello ${name}!\n";

The escapes are \n, \t, \r, \\, \", \0 (and the other single-letter C escapes), plus \u{HEX} for a Unicode code point by hex value:

io.println("tab\tend");
io.println("\u{41}");      # A
io.println("\u{20AC}");    # €
io.println("\u{1F600}");   # 😀  (U+1F600)

\u{...} is the idiomatic way to write a code point in a literal (the same value string.fromCodePoint(...) produces). It takes 1 to 6 hex digits; an empty, out-of-range, or surrogate value is a compile error. Escapes, including \u{...}, are decoded inside interpolated strings too, so "emoji \u{1F600} for ${name}" works as expected.

Any expression can appear inside ${...}:

io.println("${a} + ${b} = ${a + b}");
io.println("count: ${items.length()}");

Non-string values are automatically converted to their string representation:

let n = 42;
io.println("n = ${n}");  # n = 42

Single-quoted strings are raw - no escape processing and no interpolation:

string raw = 'Hello ${name}\n';  # literal: Hello ${name}\n

Multiline strings use triple quotes:

let user = {"name": "Ada"};
string html = """
<h1>${user["name"]}</h1>
""";

Choose single-quoted strings for regex patterns, shell fragments, or examples where backslashes should stay literal. Choose triple-quoted strings for HTML, SQL, Markdown, and larger fixture text.

An interpolation expression can itself contain string literals, so a dictionary key works directly with double quotes in any interpolated string, regular or triple-quoted: the parser skips nested string literals when it scans for the closing }. Single-quoted keys are equivalent, and the expression can be anything:

let foo = {"bar": 1};
io.println("${foo["bar"]}");        # 1   (double-quoted key)
io.println("${foo['bar']}");        # 1   (single-quoted key, equivalent)
io.println("${foo["bar"] + 10}");   # 11  (any expression, nesting allowed)

Format specifiers

An interpolation can carry an optional :spec formatter that follows Python's mini-language. The spec is parsed as

[[fill]align][sign][#][0][width][,][.precision][type]

with the following type characters:

Type Use
d integer decimal
x / X hex (lower / upper)
o octal
b binary
f fixed-point float (f works on decimal too)
e scientific notation
g general float (auto-pick fixed or scientific)
s string (default)
% percentage (value * 100, suffixed with %)
let pi    = 3.14159;
let big   = 1234567;
let label = "Ada";

io.println("${pi:.2f}");        # 3.14
io.println("${big:,}");         # 1,234,567
io.println("${42:>5}|");        # "   42|"  (right-align width 5)
io.println("${42:<5}|");        # "42   |"  (left-align)
io.println("${42:^5}|");        # " 42  |"  (center)
io.println("${42:05}");         # 00042     (zero-pad)
io.println("${42:*>4}");        # **42      (custom fill)
io.println("${255:x}");         # ff
io.println("${255:#x}");        # 0xff
io.println("${42:+d}");         # +42       (forced sign)
io.println("${0.125:.2%}");     # 12.50%
io.println("${label:>10}|");    # "       Ada|"
io.println("${label:.3}");      # Ada

A bare ${expr} without a : behaves as before - the value is converted using its default representation.

Numeric specs (f, e, g, %) on a decimal format from the decimal's exact value rather than a binary float approximation, so ${d:.Nf} matches d.toString(N):

let d = 3.1415926536 as decimal;
io.println("${d:.13f}");      # 3.1415926536000
io.println(d.toString(13));   # 3.1415926536000  (identical)

When the expression itself contains a ternary ?:, the format-spec : doesn't get confused: ${cond ? a : b} keeps the inner : as part of the ternary. To attach a spec to a ternary result, parenthesise the expression: ${(cond ? a : b):03d}.

Numbers

Three numeric types:

int     count  = 10;
decimal money  = 12.50;
float   ratio  = 0.25f;

Use int for counts, indexes, and whole-number values. Use decimal for money, measurements, and any quantity where rounding errors are unacceptable. Use float for scientific or graphics calculations where IEEE 754 semantics and performance matter more than exactness.

Integer literals support decimal, binary, octal, hexadecimal, and _ separators:

let flags   = 0b1010;
let mode    = 0o644;
let mask    = 0xFF;
let million = 1_000_000;

Decimal and float literals also support scientific notation. An exponent makes an unsuffixed literal a decimal; add the f suffix when you need an IEEE 754 float:

let exact = 1.5e-3;   # decimal
let wide  = 1e308f;   # float

The decimal type

decimal stores values as exact rational numbers (numerator/denominator pairs). Arithmetic never rounds - 0.1 + 0.2 is exactly 0.3, not a floating-point approximation:

decimal a = 0.1;
decimal b = 0.2;
decimal c = a + b;
io.println(c);   # 0.3000000000 (exact)

Because values are stored as fractions, dividing 1 by 3 produces the exact fraction 1/3, not a truncated approximation:

decimal third = 1.0 / 3.0;
io.println(third);   # 0.3333333333  (displayed to 10 decimal places)
io.println(third * 3);  # 1.0000000000  (exactly 1, no rounding error)

The default display always uses 10 decimal places, rounding the last digit where needed. The stored value is always exact regardless of what the display shows.

Controlling decimal places

toString(scale) and format(scale) format a decimal to a specific number of decimal places:

decimal price = 4.0 / 3.0;

io.println(price.toString());    # 1.3333333333  (default: 10 dp)
io.println(price.toString(2));   # 1.33
io.println(price.toString(4));   # 1.3333
io.println(price.format(2));     # 1.33  (same result; format requires scale)

toString() with no argument is equivalent to toString(10). format(scale) always requires the scale argument.

Casting to string also uses 10 decimal places:

let s = price as string;   # "1.3333333333"

When you need to display a currency value at exactly two places, always call .format(2) or .toString(2) explicitly:

decimal subtotal = 19.99;
decimal tax      = subtotal * 0.2;
io.println("Tax: " + tax.format(2));   # Tax: 4.00

Rounding to an integer

math.floor, math.round, and math.ceil accept decimal and return int:

import math;

io.println(math.floor(2.9 as decimal));   # 2
io.println(math.round(2.5 as decimal));   # 3
io.println(math.ceil(2.1 as decimal));    # 3

To keep the result as a decimal, use the value-keeping methods. Each takes an optional number of decimal places (default 0) and returns the same numeric type. round rounds half away from zero:

io.println((2.567).round(2));      # 2.57
io.println((2.5).round());         # 3
io.println((2.9).floor());         # 2
io.println((2.1).ceil());          # 3
io.println((2.999).truncate(2));   # 2.99

These work on float too ((3.14159f).round(2) -> 3.14f).

Mixed arithmetic

Arithmetic between decimal and int promotes the int to decimal and returns decimal:

decimal price = 9.99;
int     qty   = 3;
decimal total = price * qty;   # 29.97 (exact)

Arithmetic between decimal and float is not directly supported - cast one side explicitly:

decimal d = 1.5;
float   f = 2.0f;
decimal result = d * (f as decimal);

Type conversions

decimal d = 3.75;
int     i = d as int;     # 3  (truncates toward zero)
float   f = d as float;   # 3.75 (approximate; may lose precision)
string  s = d as string;  # "3.7500000000"

# Convert from string or int
decimal fromStr = "12.50" as decimal;
decimal fromInt = 7 as decimal;

as type is the idiomatic cast. The equivalent conversion methods (toInt, toDecimal, toFloat, toString, toBool) are an alternative that allows chaining and, for toDecimal, finer control: it takes an optional precision and rounds to that many decimal places in a single step.

import math;

decimal pi4 = math.pi().toDecimal(4);   # 3.1416 (rounded, as a decimal)
decimal exact = (7).toDecimal();         # 7 (no rounding)

Casting a fractional decimal to int truncates toward zero. If exact integer conversion is needed, verify first:

if (d.isZero() || (d - (d as int as decimal)).isZero()) {
    int whole = d as int;
}

Collections

list<int> nums = [1, 2, 3];
dict<string, int> scores = {"ada": 10, "grace": 12};
set<int> ids = {1, 2, 2, 3};

Indexing is zero-based. Negative indexes count from the end:

nums[0];
nums[-1];
scores["ada"];

Use length() to count the number of elements or entries in a collection:

list<int> nums = [1, 2, 3];
set<int> unique = {1, 2, 2, 3};
dict<string, int> scores = {"ada": 10, "grace": 12};

io.println(nums.length());   # 3
io.println(unique.length()); # 3
io.println(scores.length()); # 2

Use isEmpty() when you only need to know whether a collection has no elements:

if (!nums.isEmpty()) {
    io.println(nums.first());
}

Use hasKey or contains for dictionary key membership:

let data = {"name": "Ada", "middle": null};
io.println(data.contains("middle")); # true
io.println(data.hasKey("missing"));  # false

List mutation works in place (1.16.0): push, removeAt, and the other mutators modify the receiver and return it, so calls chain. append and extend also mutate in place but return null. Dict mutators (set, delete) modify in place too.

nums.append(4);          # in place; nums is now [1, 2, 3, 4]
nums.push(5);            # in place; returns nums, so pushes chain
nums.removeAt(0);        # in place; removes index 0
scores.set("linus", 7);  # in place
scores.delete("ada");    # in place

Use copy() (or sorted() / reversed()) when you need a modified copy that leaves the original untouched.

Use collections.map, collections.filter, and related helpers when you want to return a transformed collection rather than update the original.

Spread in collection literals

A list, dict, or set literal can include ...source entries that splice the source collection's elements into the new collection. The pattern mirrors how ...args works at function call sites.

let xs = [1, 2, 3];
io.println([0, ...xs, 4]);                 # [0, 1, 2, 3, 4]

let defaults = {"port": 80, "tls": false};
let opts     = {...defaults, "port": 443}; # {"port": 443, "tls": false}

let extra = {"x": 0, ...defaults};         # {"x": 0, "port": 80, "tls": false}

let small = {1, 2, 3};
let big   = {0, ...small, 4};              # set{0, 1, 2, 3, 4}

Rules:

  • List spread requires a list source. The source's elements are inserted in order at the spread position.
  • Dict spread requires a dict source. Subsequent entries (and later spreads) overwrite earlier values on key collision - last write wins.
  • Set spread accepts a set or a list source; duplicates collapse naturally.
  • A literal whose entries are all spreads ({...a}, {...a, ...b}) is treated as a dict by default. To force a set, include at least one bare element: {x, ...s}.

Operators

Arithmetic, comparison, equality, boolean, bitwise, null coalescing, optional chaining, ternary, and casts are available:

let total = price * quantity;
let ok = enabled && count > 0;
let name = maybeName ?? "anonymous";
let city = user?.address?.city;
let text = value as string;
let label = count > 0 ? "items" : "empty";

The in operator tests membership and returns a bool: element for lists, key for dicts, member for sets, substring for strings, and value-in-range for ranges. Negate with !. (A range literal needs parentheses because .. binds looser than in: x in (1..10).)

io.println(2 in [1, 2, 3]);        # true
io.println("id" in {"id": 1});     # true (key membership)
io.println("ell" in "hello");      # true (substring)
io.println(5 in (1..10));          # true
io.println(!(9 in [1, 2, 3]));     # true

User classes can support in by implementing __contains (see the classes chapter); for x in collection loops are unaffected by the operator.

The ternary operator condition ? then_expr : else_expr is a compact inline conditional. The condition must be a bool - values are never implicitly treated as truthy or falsy:

let status = isActive ? "on" : "off";
io.println(score > 90 ? "A" : (score > 70 ? "B" : "C"));

Compound assignment operators are available for all binary operators:

x += 1;    x -= 1;    x *= 2;    x /= 4;    x //= 3;
x %= 10;   x **= 2;
x &= 0xff; x |= 0x01; x ^= mask; x <<= 2;   x >>= 1;
n ??= "default";   # assigns only if n is null

Conditions are explicit. Values are not implicitly treated as truthy or falsy:

if (name != "") {
    io.println(name);
}

if (items.length() > 0) {
    io.println(items.first());
}