Operators & Arithmetic
The full C++ operator set with C++ precedence, minus the undefined behavior: integer overflow raises a catchable error instead of wrapping silently, division by zero raises on ints and floats, and nothing in the arithmetic core is UB.
<=> returns a negative, zero, or positive ordering. Numbers and text compare by value; bool orders false before true; arrays and vectors compare lexicographically; maps compare ordered key/value entries, keys by the map's own key rules (a class value key by its class, Key types); shared/weak handles and function values compare identity. References compare their referents. A class supplies its own operator<=>; an explicit relational operator wins over rewriting through it. Float <=> treats signed zeros as equivalent and places all NaNs after numbers, while ordinary float equality and relational operators keep IEEE behavior.
The table
| Category | Operators | Notes |
|---|---|---|
| Arithmetic | + - * / % | int op int stays int (7 / 2 → 3); any float operand promotes |
| Unary | - + ! ~ ++ -- | ++/-- in both prefix and postfix form; + is the number itself and promotes a char to its int |
| Compound assign | = += -= *= /= %= &= |= ^= <<= >>= | typed targets convert C++-style (see Type Ladder); the target is evaluated once. The bitwise forms take integer (or char) operands like their binary operators. |
| Comparison | == != < > <= >= <=> | the builtin <=> returns -1 / 0 / 1, a class's operator<=> its own result as returned; 1 == 1.0 is true; == is memberwise on arrays, maps and class values (below) |
| Logical | && || ! | short-circuit |
| Bitwise | & | ^ ~ << >> | on integers |
| Null | ?? ??= | a ?? b is a unless it is null, else b; a ??= b stores b only into a null a (below) |
| Ternary | ?: | right-associative |
| Member/index | . -> [] ?. :: | -> is a synonym for . (values and handles alike), ?. is null-safe and skips the rest of its chain (below), :: reaches statics/namespaces |
| Cast | cast<T>(e) cast<T&>(e) cast<shared_ptr<T>>(e) | the explicit typed boundary: copy, reference, handle. See Type Ladder |
String + concatenates ("a" + "b"), and + with a string operand stringifies the other side ("a" + 5 is "a5"). += onto a string is stricter: it appends strings and chars, and raises on anything else. Write s += to_string(n) when you mean it. Comparison on strings is lexicographic. char operands promote to int (unsigned, 0–255) in every numeric context (see below).
Arithmetic, precedence, inc/dec
print("7 / 2 = {}", 7 / 2); // int / int stays int
print("7.0 / 2 = {}", 7.0 / 2); // any float operand promotes
print("7 % 3 = {}", 7 % 3);
print("2 + 3 * 4 = {}", 2 + 3 * 4); // usual precedence
auto x = 5;
print("x++ = {}, then x = {}", x++, x);
print("--x = {}", --x);
-x;7 / 2 = 3
7.0 / 2 = 3.5
7 % 3 = 1
2 + 3 * 4 = 14
x++ = 5, then x = 6
--x = 5
== result: -5Comparisons, <=>, short-circuit
print("3 <=> 7 = {}", 3 <=> 7); // spaceship: -1 / 0 / 1
print("7 <=> 7 = {}", 7 <=> 7);
print("9 <=> 7 = {}", 9 <=> 7);
print("\"a\" < \"b\" = {}", "a" < "b");
print("1 == 1.0 = {}", 1 == 1.0);
auto side_effects = 0;
function tick() { side_effects += 1; return true; }
auto r = false && tick(); // short-circuit: tick never runs
print("side_effects = {}", side_effects);
true || tick();
side_effects;3 <=> 7 = -1
7 <=> 7 = 0
9 <=> 7 = 1
"a" < "b" = true
1 == 1.0 = true
side_effects = 0
== result: 0Float comparisons follow IEEE: a NaN operand makes ==, <, <=, > and >= false and != true, on every path (a condition, a loop head, an array compared element by element). <=> supplies a total numeric order: signed zeros are equivalent and all NaNs form the final equivalent group. Default float sorting uses this order. Map keys keep their separate total order, so a NaN key is found again.
Null: ?., ?? and ??=
x?.member is null when x is null, and the rest of the chain after it is skipped: x?.a.b, x?.items[0] and x?.f().g() are all null for a null x. Only the value in front of the ?. is tested, as in JavaScript and C#: a null x.a still raises at .b, so a nullable link needs its own ?.. a ?? b is a when it is not null and b otherwise, and b runs only then; 0, false and "" are values, not null. a ??= b assigns b only when a holds null, and its value is what a holds afterwards:
class Weapon { string name = "axe"; }
class Hero { var weapon = null; }
Hero bare;
Hero armed;
armed.weapon = Weapon();
var nobody = null;
print(bare.weapon?.name ?? "fists", " ", armed.weapon?.name ?? "fists", " ", nobody?.weapon.name ?? "no hero");
var settings = {"volume": 7};
print(settings["volume"] ?? 5, " ", settings["music"] ?? 5, " ", 0 ?? 5);
var cache = null;
cache ??= [1, 2];
cache ??= [9];
print(to_json(cache));fists axe no hero
7 5 0
[1,2]?? binds more loosely than || and more tightly than ?:, and groups to the right: a ?? b || c is a ?? (b || c) and a ?? b ? x : y is (a ?? b) ? x : y. A missing map key reads as null, so m[k] ?? fallback reads a default without inserting (m.get(k, fallback) says the same). The target of ??= is read and then perhaps stored, so it is a variable, a member or an element whose index runs no code; a[i++] ??= v is an error at load that asks for a local.
Evaluation order is a contract
C++17's order: the callee before its arguments, the arguments left to right, and in an assignment the right side before every part of the target.
In a call, the callee expression is evaluated before any argument, and arguments go left to right. Binary operators evaluate their left operand first. This is the C++17 rule, it holds on both backends, and it is the order errors surface in too: if the callee and the first argument both fail, you see the callee's error.
function a() { print("argument a"); return 1; }
function b() { print("argument b"); return 2; }
function add(x, y) { return x + y; }
function pick() { print("callee"); return add; }
print("sum = ", pick()(a(), b()));callee
argument a
argument b
sum = 3Why it matters: a callee expression that reads a global runs before an argument that changes it, and a side effect in argument two never precedes one in argument one. You can rely on that, in either backend, rather than hoping.
Each argument also takes its value when it is evaluated, as in Lua, Python and JavaScript. A later argument that writes a variable, an element or an object an earlier argument read does not change what the callee receives. A reference parameter binds the storage itself, so it sees the write:
var scores = [10, 20, 30];
function bump() { scores[1] = 99; return 1; }
function add(x, y) { return x + y; }
function add_ref(int& x, y) { return x + y; }
print("by value: ", add(scores[1], bump()));
scores[1] = 20;
print("by reference: ", add_ref(scores[1], bump()));by value: 21
by reference: 100A reference parameter binds when its argument is evaluated, as in C++. The object a field argument reads from and the index an element argument names are the ones evaluation saw; a later argument that reassigns the receiver or moves the index changes what the variables hold, never which storage the parameter names:
class Counter { int n = 0; }
var first = new Counter();
var second = new Counter();
var c = first;
var hits = [0, 0];
var i = 0;
function inc(int& x, y) { x += 1; return y; }
inc(c.n, c = second); // binds first.n, then c changes
inc(hits[i], i++); // binds hits[0], then i moves
print(first.n, " ", second.n, " ", hits[0], " ", hits[1]);1 0 1 0Assignment runs the other way, again as in C++17: in target = value and target op= value the right side is evaluated first, then the target. For obj.f op= e, this.f op= e, C::s op= e and ns::x op= e that means e, then the receiver, then the member's read (a user getter included), the arithmetic and the store; for a[j] = e and a[j] op= e, e, then a, then j, then the element. An error in the right side is the one you see, and the target is never touched:
string log = "";
class Box { int v = 1; }
shared_ptr<Box> b = new Box();
function target() -> shared_ptr<Box> { log += "target "; return b; }
function value() -> int { log += "value "; return 5; }
target().v += value();
print(log + "-> " + b.v);value target -> 6Bitwise
print("6 & 3 = {}", 6 & 3);
print("6 | 3 = {}", 6 | 3);
print("6 ^ 3 = {}", 6 ^ 3);
print("~0 = {}", ~0);
print("1 << 4 = {}", 1 << 4);
print("32 >> 2 = {}", 32 >> 2);
auto flags = 0b0001;
flags |= 0b0100; // flags = flags | 0b0100, flags evaluated once
flags;6 & 3 = 2
6 | 3 = 7
6 ^ 3 = 5
~0 = -1
1 << 4 = 16
32 >> 2 = 8
== result: 5Chars promote like C++
A char operand enters every binary numeric context (arithmetic, bitwise, and comparisons) as an integer in 0–255, always unsigned (signed char is a binary-data footgun this language opts out of). s[i] reads a char from a string, so byte-level parsing is ordinary arithmetic:
var s = "abc";
print("'z' - 'a' = {}", 'z' - 'a');
print("s[1] == 'b': {}", s[1] == 'b');
print("s[0] == 97: {}", s[0] == 97);
print("s[0] + 0 = {}", s[0] + 0);
print("'a' & 0x7F = {}", 'a' & 0x7F);
type_of('a' + 1);'z' - 'a' = 25
s[1] == 'b': true
s[0] == 97: true
s[0] + 0 = 97
'a' & 0x7F = 97
== result: intCompound assignment and ++/-- promote the same way, wherever the char is held: a var local, field or element holding 'b' holds the int 97 after -= 1 and 99 after ++, while a variable declared char refuses the int (Cannot assign int to 'c' (declared char), a load error when the checker sees it).
Promotion never crosses into the string domain: 'a' == "a" is false, and char + string concatenates as text. And there is no implicit int→char either: type_of('a' + 1) is int, and char c = 65 is a type error. The char↔int door is cast<T> (cast<char>(n) for an int in 0..255, cast<int>(c) for the code point), with to_char(n) as the stdlib function form of the same range-checked rule, and \xNN literals for bytes you know at write time:
char c = cast<char>(65); // int -> char, range-checked 0..255
int n = cast<int>('a'); // char -> int: the code point
print(c, " ", n, " ", cast<string>('z') + "!", " ", cast<char>("q"));
print(to_char(66)); // the stdlib function form of the same rule
try { var bad = cast<char>(256); } catch (e) { print("caught: ", e); }A 97 z! q
B
caught: Cannot cast 'int' to 'char'The byte-work recipes (s[i] - '0', little-endian words, out += to_char(b)) are in Containers & Strings.
Checked overflow: raises, never wraps, never UB
The default build policy is JAISCRIPT_CHECKED_OVERFLOW ("safe by default"): overflowing + - *, unary -, and INT64_MIN / -1 raise a catchable run-time error. The alternative build policy (JAISCRIPT_WRAP_ON_OVERFLOW) wraps two's- complement instead. Either way, integer arithmetic is never undefined behavior. Internally it is computed in unsigned. engine::throw_on_overflow() reports which policy the build uses.
auto big = 9223372036854775807; // INT64_MAX
try {
big = big + 1; // checked overflow raises, never wraps or UB
} catch (e) {
print("caught: {}", e);
}
big;caught: Integer overflow in '+'
== result: 9223372036854775807Note the value is untouched after the failed operation. ++/-- are covered by the same policy, as are the intermediate results of compound assignments.
When wraparound is the point (a hash, a checksum, a random generator), say so: math::wrap_add(a, b), math::wrap_sub(a, b) and math::wrap_mul(a, b) compute the two's-complement result modulo 264 in every build, with no mask to keep a product small, and the result of a shift << / >> always wraps (the count must be 0 to 63, else Shift amount must be between 0 and 63). An FNV-1a hash:
int h = -3750763034362895579; // the FNV-1a offset basis, 0xcbf29ce484222325, as an int
for (auto c : "abc") {
h = math::wrap_mul(h ^ c, 1099511628211); // the FNV prime; the product wraps modulo 2^64
}
print("fnv1a(\"abc\") = {}", h);fnv1a("abc") = -1792535898324117685Division by zero
Both integer and float division by zero raise a catchable error (floats do not produce IEEE infinity):
try {
auto x = 1 / 0;
} catch (e) {
print("int: caught {}", e);
}
try {
auto y = 1.0 / 0.0; // float division by zero also raises (no IEEE inf)
} catch (e) {
print("float: caught {}", e);
}
"still running";int: caught Division by zero
float: caught Division by zero
== result: still runningEquality: memberwise values, pointer-compared handles
== and != compare values memberwise, the way C++ containers do: arrays element by element, maps entry by entry in key order, class instances field by field, each stopping at the first difference and short-circuiting on a size or class mismatch. A class that defines operator==, or operator<=> (below), takes precedence, for itself and wherever it appears as an element or a field. A shared_ptr handle compares like std::shared_ptr, by pointer: two new objects with equal fields are not equal, a handle equals itself and its aliases. Identity for everything else is a separate question, asked with same_object(x, y): references resolve to their referents, handles to the object they share, and containers and instances are the same object only when they are one allocation. Primitives and strings carry no identity, so same_object compares them as values.
class Stats { int hp = 10; array<int> buffs = [1, 2]; Stats() {} }
Stats a = Stats();
Stats b = Stats(); // a second value with the same fields
Stats c = Stats(); c.buffs.push(3);
print("values: {} {}", a == b, a == c);
print("arrays: {} {}", [1, [2, 3]] == [1, [2, 3]], [1, 2] == [1, 2, 3]);
print("maps: {}", {"x": 1, "y": 2} == {"y": 2, "x": 1});
var h1 = new Stats();
var h2 = h1; // a var copy of a handle shares it
var h3 = new Stats(); // a different object with equal fields
print("handles: {} {}", h1 == h2, h1 == h3);
print("identity: {} {} {}", same_object(a, b), same_object(h1, h2), same_object(a, a));values: true false
arrays: true false
maps: true
handles: true false
identity: false true trueMap keys compare under the map's own ordering, so {1: "a"} and {1.0: "a"} hold different keys and are not equal even though 1 == 1.0. Arrays and maps also order lexicographically under <, <=, >, >= and <=> ([1, 5] < [2] is true). A class instance has no builtin order: a < b on two instances raises Invalid operands for < operator unless the class declares operator< or operator<=> (below). Values compare at any depth (the comparison walks them on a work list, not the native stack); a cycle cannot arise, since a value cannot contain its owner and handles compare by pointer.
Operator overloading
C++'s overloadable set and C++20's comparison rewriting: declare operator<=> and <, <=, >, >= follow, and == too when no operator== takes the operand. There is no operator!: as with explicit operator bool, one conversion, to_bool(), answers both !v and if (v).
Script classes define operators as methods, with class instances or even primitives on the right-hand side. C++-bound classes get the same treatment through dynamic_binder (see Embedding). One detail worth knowing: an operand that flows into your operator arrives as written. A char is not promoted on the way in.
class Vec {
int x = 0; int y = 0;
Vec(int px, int py) { x = px; y = py; }
Vec operator+(Vec other) { return Vec(x + other.x, y + other.y); }
bool operator==(Vec other) { return x == other.x && y == other.y; }
Vec operator*(int k) { return Vec(x * k, y * k); }
}
auto a = Vec(1, 2);
auto b = Vec(3, 4);
auto c = a + b;
print("a + b = ({}, {})", c.x, c.y);
print("equals: {}", c == Vec(4, 6));
auto d = a * 3;
print("a * 3 = ({}, {})", d.x, d.y);a + b = (4, 6)
equals: true
a * 3 = (3, 6)The overloadable set is C++'s, less &&, ||, the comma, -> and new/delete: binary + - * / % & | ^ << >> < > <= >= == != <=>, unary - + ~, and =, [], (). An operator= runs on every = into an existing object of the class whose overloads take the right-hand value, in a variable, field, static, element or map value declared the class or in a var; a declaration, a parameter and a return copy-construct instead (Classes). The parameter count tells a unary operator from a binary one, as in C++: operator-() negates, operator-(Vec other) subtracts. Your operator runs when its left operand (a unary operator's only operand) is an instance of the class or a subclass (the exception is == and != in a class without <=>, which answer only values of the class, below); every other operand keeps the builtin meaning, and an operator the class does not declare raises the builtin text (-v without operator-() is Unary minus requires numeric operand). As in C++ overload resolution, your operator beats a builtin meaning the operands also have: with string operator+(string s), v + "!" runs it instead of concatenating; when no overload takes the right operand (only operator+(Vec other)), v + "!" is the builtin concatenation of v's text. A compound assignment is the binary operator and a store: a %= b is a = a % b through your operator%, with no separate operator%= in a script class (a C++ class can bind one, which then runs in place; see the dynamic binder). There is no operator!: as with a C++ operator bool, !v and if (v) go through the one conversion, to_bool(), so !v is !to_bool(v) and the two never disagree. Declaring operator! is a parse error that says so (operator! cannot be declared: !v is !to_bool(v); declare bool to_bool()). An instance of a class without to_bool() is truthy, like any non-null value.
class Bits {
int v = 0;
Bits(int x) { v = x; }
Bits operator&(Bits o) { return Bits(v & o.v); }
Bits operator|(Bits o) { return Bits(v | o.v); }
Bits operator<<(int k) { return Bits(v << k); }
Bits operator~() { return Bits(~v & 255); }
bool to_bool() { return v != 0; }
Bits operator-() { return Bits(0 - v); }
int operator<=>(Bits o) { return v < o.v ? -1 : v > o.v ? 1 : 0; }
}
Bits flags = Bits(5);
flags |= Bits(8);
flags <<= 1;
print("{} {} {} {}", flags.v, (~flags).v, !Bits(0), (-flags).v);
print("{}", Bits(3) <=> Bits(7));26 229 true -26
-1The comparisons follow C++20's rewriting. A class that declares operator<=> gets <, <=, > and >= from it: a < b is (a <=> b) < 0, a >= b is (a <=> b) >= 0, the result (an int or a float) compared with zero. An operator the class chain declares wins over the rewrite for an operand one of its overloads takes, so a class with its own operator< keeps it and takes the other three from <=>; with operator<(Version o) and operator<=>(int m), v < 5 is the rewrite. <=> drives every comparison it can take, against any operand and in both orders. The right operand answers when the left one has no operator for it: 5 < v is 0 < (v <=> 5) and 5 <=> v is 0 <=> (v <=> 5), in the builtin order (a NaN result orders last). Equality comes from it too: when an operand's chain declares <=>, a == b is that class's operator== if one of its overloads takes the other operand, else (a <=> b) == 0, the left operand first, then the right one, so price == 500 and 500 == price both work with an operator<=>(int), and two values of the class compare through it in place of the memberwise default (above). An overload counts only when its parameter takes the other operand; otherwise the builtin meaning stands (Invalid operands for < operator, or == false). a != b is !(a == b), unless the class declares operator!=, and x == null stays a null test. A class without <=> compares against values of the class (through its operator== or operator!= when it declares one, memberwise otherwise) and against any operand one of its overloads with a typed parameter takes, from either side as C++20 reverses ==: with operator==(int n), v == 3 and 3 == v both run it. Against any other operand == is false and != true. A bound C++ class compares the same way through the operators its binding registers; one that binds neither == nor <=> compares its values by identity, the same object only. sort() orders through the same <.
class Version {
int major = 0; int minor = 0;
Version(int a, int b) { major = a; minor = b; }
int operator<=>(Version o) { return major != o.major ? major - o.major : minor - o.minor; }
int operator<=>(int m) { return major - m; } // against a bare major number
}
Version v1 = Version(1, 4);
Version v2 = Version(2, 0);
print("{} {} {} {}", v1 < v2, v1 >= v2, v2 > v1, v1 <= Version(1, 4));
print("{} {}", v1 == Version(1, 4), v1 != v2);
print("{} {} {}", v2 == 2, 1 < v2, 3 <=> v1);
array<var> versions = [v2, Version(0, 9), v1];
versions.sort();
print("{}.{}", versions[0].major, versions[0].minor);true false true true
true true
true true 1
0.9An operator() makes instances callable. A call whose callee holds such an instance runs it with the call's arguments, at any arity and with overloads chosen as for any method, whether the instance sits in a local, a global, a field, an array element or a shared_ptr. It keeps its state between calls (more in Classes). An operator[] that returns T& makes the subscript an lvalue, so g[i] = v and g[i] op= v store through it; one that returns by value makes g[i] a read, and a store to it raises Cannot assign to a call result: 'operator[]' returns by value (a load error when the checker knows the class):
class Grid {
array<int> cells = [0, 0, 0, 0];
int& operator[](int i) { return cells[i]; } // a reference return: g[i] is an lvalue
}
Grid g;
g[2] = 7;
g[2] += 1;
print("{} {}", g[2], to_json(g.cells));
class Adder {
int calls = 0;
int operator()(int a, int b) { calls += 1; return a + b; }
}
Adder add;
print("{} {} calls={}", add(2, 3), add(4, 5), add.calls);8 [0,0,8,0]
5 9 calls=2