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 fineVariables 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: localConstants
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() // → 4The 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 3Return 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() // → 3arr[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() // → 2What 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:
| Type | Example | What it is |
|---|---|---|
int | 42, -7, 0 | 64-bit whole number |
decimal | 3.14, 0.5, 2.0 | 64-bit floating point |
dec | 12.50m, 5m, 1e-7m | Exact base-10 decimal (28–29 digits) |
bool | true, false | Boolean value |
string | "hello", r"raw {x}" | UTF-8 text (interpolated or raw) |
null | null | Absence 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:
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.50int 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\{ / \} 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.5Comparison & 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 = 6Ternary
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 // → twoNull coalescing
let value = null
out value ?? "default" // → default
let maybeNum = findIndex("Ana", names)
let safe = maybeNum ?? -1 // -1 if not foundLogical 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 bWith 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 somethingWhat counts as falsy
One rule, shared by && / ||, the ternary, match guards and the filter / some / every callbacks:
| Falsy | Truthy |
|---|---|
false · null · 0 · 0.0 · "" · an empty array, dict or set | everything else |
[] 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 boolThe 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)) // → PointPipe 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 // → 11Size 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) // → 0sizeof(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") // ❌ sameComments
// Single-line comment
/* Multi-line
comment */
let x = /* inline */ 42Type 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 // → 3What 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.