Expressions
Operators and precedence, arithmetic, element-wise arrays, comparison, conditionals, lambdas, ranges, array literals and slicing, structs, tuples, blocks.
Nex is expression-oriented: nearly every syntactic construct is an expression with a type and a value.
4.1 Literal expressions
Each literal form (chapter 2) is an expression of the corresponding type.
4.2 Variable references
A bare identifier refers to a binding in scope. The expression has the type of the binding.
4.3 Operators and precedence
Operators, in order from highest to lowest precedence:
| Level | Operators | Associativity | Description |
|---|---|---|---|
| 1 | . | left | field / method access |
| 2 | f(...), a[...] | left | application, indexing |
| 3 | unary -, not | right | unary minus, logical not |
| 4 | ^ | right | exponentiation |
| 5 | juxtaposition (2x, 2(e), 2pi) | left | implicit multiplication (§4.4) |
| 6 | *, /, div, %, @ | left | multiplication, division, integer division, modulo, matrix multiply |
| 7 | +, - | left | addition, subtraction |
| 8 | .., ..= | left | range constructors |
| 9 | ==, !=, <, <=, >, >= | non-associative | comparison |
| 10 | and | left | logical and |
| 11 | or | left | logical or |
| 12 | -> | right | function / lambda |
| 13 | , | right | tuple constructor (§4.16) |
User-defined operators are deferred.
Comma binds looser than every other operator on a single line. That means if cond then a else b, c parses as (if cond then a else b), c — a 2-tuple. To put the comma inside an else branch, either parenthesize (if cond then (a, c) else (b, c)) or use the indented block form, whose last expression accepts a paren-less tuple naturally:
if cond then
a, c
else
b, c
The same single-line vs. block-form distinction applies to lambda bodies (f = (x) -> (x, -x) vs. a block body whose last item is x, -x) and to match arm bodies. See §4.17.
4.4 Arithmetic operators
The arithmetic operators +, -, *, /, ^, %, div apply to numeric operands with the following semantics:
+,-,*— work oninteger,real,complex. Promote per §3.2./— real division. Both operands promoted to at leastreal.integer / integerproducesreal.div— integer division (keyword, not a symbol —//is reserved for line comments). Both operands must beinteger. Truncates toward zero. Example:7 div 3 == 2.%— modulo. Both operands must beinteger. Result has sign of dividend.^— exponentiation.real ^ real → real,complex ^ complex → complex,integer ^ integer → integer(only for non-negative exponent; negative exponent traps).- unary
-— negation. Works on any numeric type.
Integer arithmetic traps on overflow. Real arithmetic follows IEEE 754 (NaN, infinities propagate; no traps from arithmetic itself).
Juxtaposition multiplication. A numeric literal (integer or real) immediately followed by an identifier or an opening parenthesis — with or without intervening whitespace — denotes implicit multiplication. This makes mathematical notation read naturally:
2x // 2 * x
2(x + 1) // 2 * (x + 1)
2pi // 2 * pi
3sin(t) // 3 * sin(t)
2i // 2 * i = (0.0 + 2.0i)
The rule applies only when the left operand is a numeric literal — not when it is an identifier and not when it is a parenthesized expression. Therefore:
xyis the identifierxy, notx * y.(x)(y)is a function call (xapplied toy), not multiplication.- To multiply two identifier or parenthesized expressions, write
*explicitly.
Per the precedence table (§4.3), juxtaposition binds tighter than *, /, div, and %, but looser than ^. This matches how the math reads:
2x^3is2 * (x^3)—^binds tighter than juxtaposition1/2xis1 / (2 * x)— juxtaposition binds tighter than/2x * 3is(2 * x) * 3— juxtaposition binds tighter than*
The juxtaposition rule subsumes Nex’s complex-number construction: 2i, 3 + 4i, and (a + b)i are all ordinary expressions under this rule, with i being a prelude constant of type complex (see the Prelude chapter).
4.5 Element-wise array operators
When an arithmetic operator is applied with one or both operands being an array, the operation is element-wise. The rules are:
[T] op [T]— element-wise on rank-1, requires same length, returns[T][[T]] op [[T]]— element-wise on rank-2, requires same shape, returns[[T]]arr op TandT op arr— broadcasts the scalar over the array (any rank), returns an array of the same shape and rank
Shape mismatch (length for rank-1; shape for rank-2) is a runtime trap.
val a = [1.0, 2.0, 3.0]
val b = [4.0, 5.0, 6.0]
val c = a + b // [5.0, 7.0, 9.0]
val d = a * 2.0 // [2.0, 4.0, 6.0]
val e = 2a + b // [6.0, 9.0, 12.0] (juxtaposition)
val M = [[1.0, 2.0], [3.0, 4.0]] // 2×2
val N = [[10.0, 20.0], [30.0, 40.0]]
val P = M + N // [[11.0, 22.0], [33.0, 44.0]]
val Q = 2M // [[2.0, 4.0], [6.0, 8.0]]
Element-wise expressions are guaranteed to fuse: 2a + b and its rank-2 equivalents lower to a single loop nest with no intermediate arrays allocated.
Matrix multiplication uses the @ operator (not element-wise). It is defined between rank-1 and rank-2 arrays as follows:
[[T]] @ [[T]] shapes (m, k) @ (k, n) → (m, n) matrix-matrix
[[T]] @ [T] shape (m, k) @ k → m matrix-vector
[T] @ [[T]] shape k @ (k, n) → n vector-matrix
[T] @ [T] shape k @ k → T dot product
Inner-dimension mismatch traps.
val A = [[1.0, 2.0], [3.0, 4.0]] // 2×2
val v = [5.0, 6.0]
val Av = A @ v // [17.0, 39.0] (rank-1)
val AA = A @ A // [[7.0, 10.0], [15.0, 22.0]]
val d = v @ v // 61.0 (scalar = dot)
4.6 Comparison operators
The operators ==, !=, <, <=, >, >= produce bool. Comparison is defined for:
bool == bool,bool != boolonly- All ordered numeric comparisons between numeric types after promotion
complex == complexandcomplex != complex(no ordered comparison on complex)- Element-wise array comparison:
[T] op [T] → [bool](same broadcast rules as §4.5) string == string,string != string(no ordered comparison on string)
Chained comparisons
Two or more comparison operators in a row form a chained comparison:
0 <= i < n
lo < x <= hi
a < b < c <= d
a OP1 b OP2 c ... OPn z is equivalent to
(a OP1 b) and (b OP2 c) and ... and (y OPn z)
with the additional guarantee that every inner operand is evaluated at most once and only if every earlier comparison succeeded. Concretely, 0 < f() < 10 calls f() exactly once when 0 < f() is true, and not at all when it is false. The operators may be mixed freely — 0 <= i < n and a < b == c are both well-formed — and any number of comparisons may be chained.
Each pairwise comparison must independently satisfy the type rules above; in particular a chain that mixes ordered and equality operators on values for which the relevant pairwise comparison isn’t defined is rejected by the type checker.
4.7 Logical operators
and, or, not work on bool (or [bool] element-wise). and and or are short-circuiting on scalar bool.
4.8 Function calls
f(a, b, c)
sqrt(2.0)
map(arr, x -> x * 2.0)
If f has type (T1, T2, ..., Tn) -> R, the call has type R. Arguments are evaluated left-to-right.
4.9 Method-like notation
The expression e.name(args...) is sugar for name(e, args...) when e.name does not refer to a struct field. The compiler resolves e.name as follows:
- If
e‘s type is a struct with a field namedname, treat as field access. - Otherwise, if a function
nameis in scope whose first parameter matches the type ofe, treat asname(e, args...). - Otherwise, error.
This allows the natural chaining style:
arr.map(x -> x * 2.0).sum() // equivalent to sum(map(arr, x -> x * 2.0))
4.10 Conditionals
An if expression has the form:
if cond then expr1 else expr2
It is an expression whose type is the common type of expr1 and expr2.
then is required for an inline body and optional when the body starts on a new indented line. Multi-line form (with or without then):
// `then` optional when body is indented:
if cond
expr1
else
expr2
// `then` still works for the multi-line form too:
if cond then
expr1
else
expr2
For chained conditionals — both else if and elif (single-token shorthand) are accepted, with identical semantics:
if cond1
expr1
else if cond2
expr2
else
expr3
// Same shape with `elif`:
if cond1
expr1
elif cond2
expr2
else
expr3
An if without an else is an expression of type unit. Both branches in that case must also have type unit:
if cond
print(x) // type unit
4.11 Lambdas
Lambda expressions use ->:
x -> x * x
(x, y) -> x + y
(x: real) -> x * 2.0 // optional parameter type annotation
Single-parameter lambdas may omit parentheses. Multi-parameter lambdas require them. Parameter types are inferred from context where possible; otherwise must be annotated.
Block-form lambdas:
x ->
val y = x * x
y + 1
Lambdas capture surrounding lexical bindings. Captured val bindings are captured by value (or by shared reference, per ARC). Captured var bindings are captured uniquely — only one closure may capture a given var binding, and the binding is moved into the closure (see chapter 8).
4.12 Ranges
The range expressions:
lo..hi— half-open:[lo, hi)lo..=hi— closed:[lo, hi]
Operands are integer. The result is a range, a built-in iterable type used in for loops and convertible to [integer].
0..n // 0, 1, ..., n-1
0..=n // 0, 1, ..., n
Materialization to [integer] is lazy — a range used in a for loop iterates without allocating an array.
4.13 Array literals and construction
Rank-1 literals use square brackets:
[1.0, 2.0, 3.0] // [real]
[1, 2, 3] // [integer]
[true, false, true] // [bool]
[1.0 + 2.0i, 3.0 + 4.0i] // [complex]
All elements must have the same type. Empty rank-1 literals require a type annotation:
val empty: [real] = []
Rank-2 literals are nested. Each inner array is a row of the resulting matrix:
val m = [[1.0, 2.0, 3.0],
[4.0, 5.0, 6.0]] // [[real]], 2×3 matrix
val grid = [[1, 0, 0], [0, 1, 0], [0, 0, 1]] // [[integer]], 3×3
Inner arrays must all have the same length (rectangularity); a jagged literal traps at runtime.
Arrays may also be constructed from ranges via the range function (rank-1) and from the standard-library constructors zeros, ones, fill, identity, linspace (see the Prelude chapter).
4.14 Indexing and slicing
Array indexing is 0-based. Out-of-bounds indexing traps.
Negative indices count from the end of the axis: a[-1] is the last element, a[-2] is the second-to-last, and so on. The wrap is i + length for i < 0; the bounds check then runs against the wrapped value, so a[-10] on a 3-element array still traps. Negative indices apply to both axes of a rank-2 index (m[-1, -1] is the bottom-right element), to the single-index row form (m[-1] is the last row), AND to slice and axis-range bounds (a[-3..length(a)] is the last 3 elements; m[:, -1] is the last column).
Rank-1 indexing with a single integer:
val a = [10.0, 20.0, 30.0]
a[0] // 10.0
a[2] // 30.0
a[-1] // 30.0
a[-3] // 10.0
Rank-1 slicing with a range expression returns a freshly owned array (copying elements):
a[0..2] // [10.0, 20.0]
a[1..=2] // [20.0, 30.0]
Open-ended slice bounds omit either side; the missing bound fills from the array’s runtime extent (0 for the lower side, length(a) for the upper). Each side is independent and combines with negative indices and the inclusive form:
a[2..] // [30.0] — lo given, hi defaults to length(a)
a[..2] // [10.0, 20.0] — hi given, lo defaults to 0
a[..] // full copy — both ends default
a[-3..] // last 3 elements
a[..-1] // all but the last
a[..=-1] // inclusive — full array
The open forms are only legal inside an index list; using .. or ..= outside a slice context (e.g. as a value on the right of =) is a parse error.
Strided slices sample every k-th element from the selected window via a trailing by k:
a[0..10 by 2] // every other element from indices 0..9 (exclusive)
a[1..10 by 3] // 1, 4, 7
a[0..=9 by 2] // inclusive — same as the first form on a length-10 array
a[..10 by 2] // combines with open bounds
a[-5.. by 2] // combines with negative bounds
The stride must be a positive integer; a stride of 0 or any negative value traps. Negative strides (reverse iteration) are deferred. Strided rank-2 axis ranges (m[lo..hi by k, :]) are deferred.
Strided forms are also valid assignment targets (spec §4.15): the RHS supplies one element per selected position. Length mismatch and non-positive stride trap the same way they do on read:
var a = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
a[0..10 by 2] = [1, 1, 1, 1, 1] // every-other slot
// a == [1, 0, 1, 0, 1, 0, 1, 0, 1, 0]
var b = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
b[1..10 by 3] = [200, 500, 800]
// b == [10, 200, 30, 40, 500, 60, 70, 800, 90, 100]
Rank-2 indexing uses two integer indices separated by a comma:
val m = [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]
m[0, 0] // 1.0
m[1, 2] // 6.0
The single-index form m[i] returns the i-th row as a freshly-owned rank-1 array:
m[0] // [1.0, 2.0, 3.0]
m[1] // [4.0, 5.0, 6.0]
Rank-2 slicing uses : to mean “all of this axis” and range expressions for sub-extents. Result rank depends on what’s collapsed: an integer along an axis collapses it; : or a range preserves it.
m[:, 1] // column 1 (rank-1) → [2.0, 5.0]
m[0, 0..2] // row 0, cols 0-1 (rank-1) → [1.0, 2.0]
m[0..2, 1..3] // sub-matrix (rank-2)
m[:, :] // full copy (rank-2)
All slice forms return freshly-owned arrays. View-style slicing (returning a borrow into the source array without copying) is deferred.
4.15 Slice assignment
Slice forms (the same shapes documented in §4.14) are also valid on the left of =. This is the Fortran-90 array-section assignment idiom: a single statement overwrites an entire sub-extent of a var array in place.
var xs = [10, 20, 30, 40, 50]
xs[1..4] = [200, 300, 400] // xs = [10, 200, 300, 400, 50]
xs[0..=2] = [1, 2, 3] // xs = [1, 2, 3, 400, 50]
var m = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
m[1, :] = [40, 50, 60] // replace row 1
m[:, 0] = [-1, -4, -7] // replace column 0
m[0..2, 0..2] = [[10, 20], [30, 40]] // replace a 2×2 sub-matrix
Shape conformance. The right-hand side must match the slice’s shape:
- A rank-1 slice (
a[lo..hi],a[lo..=hi]) expects a rank-1 array of the same length. - A rank-2 slice with both axes preserved (
m[r1..r2, c1..c2],m[:, :]) expects a rank-2 array of matching shape. - A rank-2 slice with one axis collapsed (
m[i, c1..c2],m[:, j],m[i, :]) expects a rank-1 array of matching length.
Length / shape mismatches trap at runtime, as does any out-of-bounds slice bound.
In-place mutation. The underlying buffer is rewritten — the var binding does not acquire a new identity. The assignment is observable through every binding that aliases the same underlying array, and no clone fires from this construct alone.
When to reach for slice assignment. Three common cases:
- Replacing a contiguous prefix / suffix / middle of a rank-1 buffer, where you’d otherwise write an indexed loop.
- Writing one row, column, or sub-block of a rank-2 matrix that you’ve computed independently —
result[py, :] = row_vectoris the working idiom (see the Mandelbrot example). - Avoiding a
mutparameter for “replace this region with that data” APIs, since slice assignment mutates a binding directly in its own scope without the auto-clone interaction that follows passing avararray to amutparameter (§8.3).
Strided slice forms (xs[lo..hi by k] = rhs) are deferred.
4.16 Struct construction and access
val p = Point(3.0, 4.0)
val x = p.x // 3.0
Struct field reassignment is allowed only on var bindings (see chapter 5):
var p = Point(3.0, 4.0)
p.x = 10.0 // ok
4.17 Tuple construction and access
Tuples are constructed by comma-separated expressions (length ≥ 2). Parentheses are not required:
val t = 1, 2.0, "three" // type: integer, real, string
val pair = 3.0, 4.0 // type: real, real
Parentheses are optional grouping; both forms are equivalent:
val t = (1, 2.0, "three")
Parentheses become required when the surrounding context already uses commas for another purpose:
f(1, 2, 3) // 3 arguments
f((1, 2, 3)) // 1 argument, the tuple (1, 2, 3)
[1, 2, 3] // [integer] of 3 elements
[(1, 2), (3, 4)] // [(integer, integer)] of 2 tuples
Tuple elements are accessed by 0-based numeric field:
t.0 // 1
t.1 // 2.0
t.2 // "three"
Tuple destructuring in val / var bindings — no parens needed unless type-annotated:
val a, b, c = t // bind components
val x, _ = 3.14, "ignored" // _ discards
val (a, b): (integer, real) = 1, 2.0 // parens required when annotating types
Single-element tuples do not exist; 1 is just 1. The empty tuple () is already the unit literal (chapter 2) — there is no zero-arity tuple distinct from unit.
Tuples as block results. Inside an indented block — a def body, a val/var/const binding RHS that uses block form, a then / else / do body, a match arm body, or a lambda body that uses block form — the block’s last item may be a paren-less tuple:
def make_box(w: real, h: real, d: real) =
w, h, d // returns a 3-tuple
def first_positive(xs: [real]) =
for x in xs do
if x > 0.0 then return x, true // returns a 2-tuple
end for
0.0, false // fallback 2-tuple
return is a statement, so its value also accepts a paren-less tuple (return a, b, c returns the 3-tuple (a, b, c)).
4.18 Block expressions
A sequence of statements followed by a final expression is itself an expression with the type and value of the final expression. The block form arises from indentation:
val x =
val y = compute()
val z = transform(y)
z + 1 // type of the block is the type of z + 1
If the final form is a statement (e.g., assignment), the block has type unit.