Documentation menu
Language

Variables & Types

Variables are declared with let. Types are optional — you can add them for safety or leave them out for flexibility.

Variables

Use let to declare a variable. Reassign it with plain = — no let again:

Run
let name  = "Sergio"
let count = 20
let active = true

count = count + 1   // reassignment
name  = "Ana"       // also fine

Variables declared inside a block { } are invisible outside it. You can still mutate variables from an outer scope:

Run
let total = 0

{
    let local = 42   // only lives inside this block
    total = local    // outer variable — allowed
}

out total   // → 42
// out local  ❌ ERROR: Variable not found: local

Constants

Use const for bindings that should never be reassigned. Note that const protects the binding, not the internal content of the value — it does not deeply freeze composites like dictionaries or arrays.

Run
const PI  = 3.14159
const MAX = 100

PI = 3.0   // ❌ ERROR: Cannot reassign const 'PI'

// Mutating the contents of a const composite is allowed:
const dic <string, any> = ({"name", "Sergio"})
dic.name = "Jonathan"   // ✅ valid: mutating the internal dictionary
dic["age"] = 30         // ✅ valid

// But you cannot replace the binding itself:
const otroDic <string, any> = ({"name", "Ana"})
dic = otroDic           // ❌ ERROR: Cannot reassign const 'dic'

Values are copied, not shared

This is the one rule that works differently from JavaScript, Python and C#, and it is worth reading before you write your first helper. A composite — array, dict, set, instance — is copied when you assign it and when you pass it to a function. Two names never point at the same data.

Run
let a = [1, 2, 3]
let b = a          // b is a COPY of a
b.push(4)

out a.length()     // → 3   — a never changed
out b.length()     // → 4

The same applies to arguments, which is where it usually surprises people: a function that mutates what it was given is mutating its own copy, and the caller sees nothing.

Run
fn void addTo(any arr) { arr.push(99) }

let c = [1, 2]
addTo(c)
out c.length()     // → 2   — NOT 3

Return the new value instead of mutating a parameter. That is the idiomatic shape here:

Run
fn any addTo(any arr) { arr.push(99); return arr }

let c = [1, 2]
c = addTo(c)
out c.length()     // → 3
Receiver writeback vs. intermediate copies.Calling a mutating method directly on a compound place — such as an indexed element (box[0].push(9)), an object field (this.items.push(x)), or a dict key (d["list"].push(x)) — performs receiver writeback: the mutated value is written directly back to the container slot. However, assigning an element to an intermediate variable first creates a copy:
Run
let box = [[1], [2]]

// Direct place invocation performs receiver writeback:
box[0].push(9)
out box[0].length()  // → 2 (mutated in place!)

// Reading into an intermediate variable copies:
let row = box[1]
row.push(99)
out row.length()     // → 2
out box[1].length()  // → 1 (box[1] remains untouched until explicitly assigned back)

What does mutate in place is the variable itself: arr.push(x), arr[i] = x, d[k] = v and obj.field = x all work exactly as you expect. The copy only happens when a value moves — a new name, an argument, a return, or an element read out of a container.

This falls out of the memory model: there is no garbage collector and no reference counting, so nothing can be shared behind your back — which is also what makes leaving a scope free its memory in one step. The one deliberate exception is closures, which capture variables by shared cell so a counter can keep counting; see closure semantics.

Types

Serez Code has five primitive types and several compound types:

TypeExampleWhat it is
int42, -7, 064-bit whole number
decimal3.14, 0.5, 2.064-bit floating point
dec12.50m, 5m, 1e-7mExact base-10 decimal (28–29 digits)
booltrue, falseBoolean value
string"hello", r"raw {x}"UTF-8 text (interpolated or raw)
nullnullAbsence of a value
any—Accepts any value, skips type checks
void—Return type for functions that return nothing

Type annotations

Add types to function parameters and return values. When present, they're enforced at every call:

Run
fn int add(int a, int b) {
    return a + b
}

add(1, 2)        // ✅
add(1, "hello")  // ❌ TYPE ERROR: Parameter 'b' expected 'int' but received 'string'

Skip annotations when you want flexibility — the parameter accepts any value:

Run
fn multiply(a, b) {
    return a * b
}

multiply(3, 4)       // ✅
multiply(2.5, 4.0)   // ✅

Nullable types

Append ? to any type to allow null as a valid value:

Run
fn int? findIndex(string target, [string] list) {
    for (let i = 0; i < list.length; i++) {
        if (list[i] == target) { return i }
    }
    return null
}

let idx = findIndex("Ana", ["Bob", "Ana", "Lee"])
if (idx != null) {
    out "Found at index {idx}"   // → Found at index 1
} else {
    out "Not found"
}

Exact decimals (dec)

decimal is f64 — fast, but binary, so 0.1 + 0.2 != 0.3. For money and anything that can't tolerate rounding drift, use dec: an exact base-10 decimal written with the m suffix.

Run
out 0.1 + 0.2 == 0.3      // false  (f64)
out 0.1m + 0.2m == 0.3m   // true   (exact)

let price = 12.50m         // inferred dec; scale preserved → "12.50"
let total = price * (1m + 0.21m)
out total                  // 15.1250

// rounding is explicit (COBOL ROUNDED == "half-up")
out (1000.00m * 0.21m).setScale(2, "half-up")   // 210.00
out Dec.fromInt(1250, 2)                          // 12.50

int mixes in exactly; mixing dec with decimal (f64) is a type error — convert with d.toDecimal() / Dec.parse. Methods: round setScale truncate scale abs floor ceil isZero sign min max toInt toDecimal toString; namespace Dec.parse / fromInt / MAX / MIN / MAX_SCALE.

String interpolation

Embed any expression directly inside a string with {}:

Run
let name = "Sergio"
let age  = 28
out "My name is {name} and I'm {age} years old."

let result = add(3, 7)
out "3 + 7 = {result}"

// Works with method calls too
out "Upper: {name.toUpperCase()}"   // → Upper: SERGIO
Tip: Use \{ / \} for literal braces inside a string ("Empty dict: \{\}"), or a raw string to disable interpolation entirely.

Raw strings (r"…")

A r"…" string disables interpolation and escape processing — braces and backslashes are literal. Ideal for literal braces, Windows paths and regexes. It cannot contain a ".

Run
let x = 5
out "value is {x}"     // value is 5     (interpolated)
out r"value is {x}"    // value is {x}   (raw)
out r"C:\temp\new"   // C:\temp\new  (no escapes)
out r"\d+\.\d{2}"   // \d+\.\d{2}  (regex literal)

Operators

Arithmetic

Run
out 10 + 3    // → 13
out 10 - 3    // → 7
out 10 * 3    // → 30
out 10 / 3    // → 3  (integer division, truncates)
out 10 % 3    // → 1  (remainder)
out 2 ** 10   // → 1024 (power)

// int and decimal mix freely
out 1 + 0.5   // → 1.5

Comparison & logical

Run
out 5 > 3      // → true
out 5 == 5     // → true
out 5 != 3     // → true

out true && false   // → false (AND)
out true || false   // → true  (OR)
out !true           // → false (NOT)

Compound assignment

Run
let n = 10
n += 5    // n = 15
n -= 3    // n = 12
n *= 2    // n = 24
n /= 4    // n = 6
n++       // n = 7
n--       // n = 6

Ternary

Run
let x = 10
let label = x > 5 ? "big" : "small"
out label   // → big

// Chain them
let n = 2
let name = n == 1 ? "one" : n == 2 ? "two" : "other"
out name   // → two

Null coalescing

Run
let value = null
out value ?? "default"   // → default

let maybeNum = findIndex("Ana", names)
let safe = maybeNum ?? -1   // -1 if not found

Logical operators (&&, ||)

They return one of the operands, not a recomputed boolean — the same as JavaScript, Python or Lua:

a && b   // a if a is falsy, otherwise b
a || b   // a if a is truthy, otherwise b

With booleans on both sides that is exactly the behaviour you expect (false && x is false, true && b is b), and the right-hand side is still not evaluated when the left one already decides the answer. What it also buys you is the conditional shape used everywhere in UI code:

Run
let name = input || "anonymous"        // fallback
let row  = items && buildRow(items)   // only when there is something

What counts as falsy

One rule, shared by && / ||, the ternary, match guards and the filter / some / every callbacks:

FalsyTruthy
false · null · 0 · 0.0 · "" · an empty array, dict or seteverything else
An empty collection is falsy — that is on purpose, and it differs from JavaScript. In JS [] is truthy, which is why items && render(items) there fires on an empty list and people reach for items.length > 0 && … instead (and then hit the one that prints a stray 0). Here the plain form already means “if there is anything”.

Type check (is)

Run
out 42 is int        // → true
out "hi" is int      // → false
out 3.14 is decimal  // → true

// Useful for functions that accept any
fn string describe(any v) {
    if (v is int)    { return "int: {v}" }
    if (v is string) { return "string: {v}" }
    return "other"
}

is returns a plain bool, so it composes with the rest of the operators. Negating it needs parentheses, because is binds looser than the ! prefix:

Run
let x = 5

out !(x is string)   // → true
out !x is string     // → false — parses as (!x) is string, and !5 is a bool

The second line is not an error, just a different question: ! follows the one truthiness rule (see below), so !5 is false, and false is string is false. Use parentheses when you mean to negate the type check.

Reading the type (type_of)

Where is answers yes/no about one type, type_of returns the type name as a string:

Run
out type_of(42)       // → int
out type_of(3.14)     // → decimal
out type_of("hello")  // → string
out type_of(true)     // → bool
out type_of(null)     // → null
out type_of([1,2,3])  // → array

// Class instances report their class name
class Point { public Point(int x, int y) { this.x = x; this.y = y } }
out type_of(new Point(1, 2))   // → Point

Pipe operator (|>)

expr |> f feeds the left-hand value into f as its single argument — it is exactly f(expr). It turns nested calls into a left-to-right read:

Run
fn int double(int n) { return n * 2 }
fn int plus1(int n)  { return n + 1 }

out 5 |> double            // → 10   (same as double(5))
out 5 |> double |> plus1   // → 11   (same as plus1(double(5)))

// Any expression that evaluates to a function works
let inc = int (int n) => { return n + 1 }
out 5 |> inc               // → 6

|> has the lowest precedence of every operator. The left side groups as you would expect, but the right side swallows whatever operator follows it — parenthesize when mixing:

Run
out 2 + 3 |> double     // → 10   left side groups first: double(2 + 3)
out 5 |> double + 1     // ❌ ERROR: '+' between 'function' and 'int'
out (5 |> double) + 1   // → 11

Size of a type (sizeof)

sizeof(T) returns the size in bytes of a type's in-memory slot, as a static int:

Run
out sizeof(int)       // → 8
out sizeof(decimal)   // → 8
out sizeof(dec)       // → 8
out sizeof(bool)      // → 1
out sizeof(string)    // → 8
out sizeof(any)       // → 8
out sizeof(null)      // → 0
out sizeof(void)      // → 0

sizeof(string) is 8 because it measures the pointer-sized handle, not the length of the text — for that use .length. Note that sizeof takes a type keyword and nothing else: passing a value or a variable fails at parse time.

Run
out sizeof(5)      // ❌ PARSE ERROR: expected ')' to close sizeof
out sizeof(x)      // ❌ same — it is not an expression
out sizeof("hi")   // ❌ same

Comments

Run
// Single-line comment

/* Multi-line
   comment */

let x = /* inline */ 42

Type conversions

Run
// String → int
out parseInt("42")      // → 42
out parseInt(3.99)      // → 3  (truncates)

// String → decimal
out parseDecimal("3.14")  // → 3.14
out parseDecimal(5)       // → 5.0

// Any type → string
out 42.toString()         // → "42"
out true.toString()       // → "true"

// Read from stdin
let input = readLine("Enter a number: ")
let num = parseInt(input)

Flash Scopes

A Flash Scope is a bare { ... } block you open on purpose inside a function or method. Everything declared between those braces exists only until the closing brace:

Run
fn int sumar(int a, int b) {
    let res = 0        // declared BEFORE the block: it survives

    {                  // ← this is the Flash Scope
        res = a + b
    }

    return res         // the return goes after the block
}

That is the whole rule: whatever is declared inside the braces is temporary, and the only way to keep something is to put it in a variable declared outside them. The same braces work at the top level of a script too, outside any function:

Run
let a   = 1
let b   = 2
let res = 0

{ res = a + b }

out res       // → 3

What they are for

Flash Scopes solve a specific problem: a computation that needs a lot of RAM but keeps only a fraction of it. Put the bulky part inside the braces, keep the piece you need in the outer variable, and everything else is released at } — not eventually, not when a collector gets around to it:

fn [string] topThree(string path) {
    let top = []                                 // the small result

    {
        let raw    = File.read(path)             // the whole file
        let rows   = raw.split("\n")             // + one string per row
        let parsed = rows.map(r => r.split(",")) // + every field of every row
        top = parsed.slice(0, 3)                 // only this is worth keeping
    }                                            // raw, rows, parsed released HERE

    return top
}

Three copies of the dataset existed inside those braces. At the closing brace the function is left holding only the three rows it was asked for. Without the inner block all three would stay alive until topThree itself returned. That is the idiom: build big, keep small, and mark the boundary with braces. Blocks nest, so you can peel in stages.

This is a feature you drive, not the memory model itself. The model underneath is region-based: values live in arenas and leaving a scope releases that region in one step, with no garbage collector anywhere. A Flash Scope is how you decide, in your own code, where one of those regions begins and ends.