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:

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:

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 values that should never change. Any attempt to reassign is an error:

const PI  = 3.14159
const MAX = 100

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

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.

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.

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:

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

let c = [1, 2]
c = addTo(c)
out c.length()     // → 3
The one that bites: indexing copies too. Reading arr[i], dict[k] or this.field hands you a copy of that element, so mutating through it is dropped — and, unlike a type error, it fails silently: no message, the program just does nothing.
let box = [[1], [2]]

box[0].push(9)      // ← does nothing at all
out box[0].length()  // → 1

// Read, modify, write back:
let row = box[0]
row.push(9)
box[0] = row
out box[0].length()  // → 2

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
anyAccepts any value, skips type checks
voidReturn type for functions that return nothing

Type annotations

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

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:

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:

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.

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 {}:

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 ".

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

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

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

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

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

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:

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)

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:

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:

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:

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:

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:

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.

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

Comments

// Single-line comment

/* Multi-line
   comment */

let x = /* inline */ 42

Type conversions

// 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:

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:

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.