Foundations Expressions and Bindings Lesson 07

Safe expressions

Mental Model

An expression is a form whose head is declared as an expression function by the active schema:

(camera :zoom (* 2 4))

Open in playground →

The expression appears where a value would appear. It is pure and bounded: no I/O, no mutation, no recursion, and no side effects.

Expression arguments are positional by default. Functions that declare parameter names also accept a Swift-style labeled call form — labels match the declared names and may appear in any order:

(lerp 0 10 0.5)              ; positional
(lerp :from 0 :to 10 :t 0.5) ; labeled — same call
(lerp :t 0.5 :from 0 :to 10) ; labeled, reordered — same call

Open in playground →

A labeled call is all-or-nothing. Mixing positional and labeled arguments in the same call is a hard error:

(lerp 0 :to 10 0.5) ; rejected — expr_mixed_args

Open in playground →

If a function does not declare labels (e.g. +, *, <), kvpairs in the argument list keep being rejected — that vocabulary doesn’t have named slots:

(+ :a 1 :b 2) ; rejected — expr_kvpair_not_allowed

Open in playground →

For labeled functions, the validator catches the usual mistakes: unknown labels, duplicate labels, and missing labels each have their own diagnostic code.

Some expression functions also declare typed signatures. The validator can catch obvious literal mistakes before evaluation:

(vec3 1 "x" 3)  ; the string should be a number
(< true "x")    ; ordering comparisons take numbers

Open in playground →

Symbols defer to runtime because they may come from let bindings or host bindings. Nested forms are classified at validate-time: a nested expression with a declared :result gets that result compared to the slot’s expected type, so (+ (vec3 1 2 3) 1) flags the vector-result argument up front. Forms whose head is an opaque expression (let, if, …) — or whose declared result is too coarse to satisfy a refined named kind — still defer:

(let [r 0.5]
  (vec3 r r r))         ; `r` is opaque; validates clean.

(+ (vec3 1 2 3) 1)      ; vec3 declares `:result vector`; `+` wants
                        ; a number → expr_type_mismatch at arg 0.

(+ 1 (let [r 1] r))     ; `let` has no declared result → defers.

Open in playground →

Worked Example

From ../../examples/with-expressions.sjon:

(+ 1 2 3)              ; 6
(* 2 3 4)              ; 24
(lerp 0 10 0.25)       ; 2.5
(clamp 1.5 0 1)        ; 1
(dot (vec3 1 2 3) (vec3 4 5 6)) ; 32

Open in playground →

The core vocabulary includes:

  • Arithmetic: +, -, *, /, mod.
  • Comparison: <, <=, >, >=, =, !=.
  • Logical: and, or, not.
  • Vectors: vec2, vec3, vec4.
  • Math: lerp, clamp, min, max, dot, cross, length, abs, sign, floor, ceil, round, fract, sqrt, pow, sin, cos, tan, asin, acos, atan, atan2, radians, degrees.
  • Constants (0-arity): pi, tau. Call as (pi) and (tau).
  • Smoothing (WGSL): saturate, step, smoothstep.
  • Vector ops: normalize, distance, reflect.
  • List ops: nth, count.
  • Seeded random: hash, rand01, rand-range, rand-int, rand-bool, rand-choice.
  • Control: let, if, cond.

Truthiness is simple: false and nil are falsy. Everything else, including 0, "", and [], is truthy.

Domain errors propagate as NaN

Operations like (sqrt -1), (asin 2), or (pow -1 0.5) return IEEE 754 NaN rather than raising an error. This keeps cross-platform behaviour bit-faithful — every host produces the same NaN — and lets manifests carry NaN through nested expressions without special-casing. If you want strict input checks, guard with (if (>= x 0) (sqrt x) ...).

Reproducible randomness

The seeded random functions are pure, deterministic functions of their seed and key arguments. They use a fixed SplitMix64-based mixer, so the same (seed, key) pair produces the same value across runs, platforms, and Zig versions:

(rand01 1 0)              ; float in [0, 1)
(rand-range 1 0 -2 5)     ; float in [-2, 5)
(rand-int   1 0 0 9)      ; integer in [0, 9] inclusive
(rand-bool  1 0 0.5)      ; true with probability 0.5
(rand-choice 1 0 [10 20 30])  ; pick an element

Open in playground →

Use seed for a stable per-document base (e.g. a scene id) and key to walk through a sequence of independent draws. Integer seeds are recommended; (rand01 1 0) and (rand01 1.0 0.0) are guaranteed to produce identical streams.

Typed signatures do not turn SJON into a static programming language. They are a validator aid for expression heads that advertise their argument shapes. Opaque or polymorphic heads still check arity first and leave value-specific failures to evaluation.

Exercises

Evaluate by hand:

(+ 1 2 3)
(- 10 3 2)
(* 2 3 4)
(/ 100 5 2)
(mod 17 5)

Open in playground →

Predict truthiness:

(and true 1 "ok")
(and true nil "never")
(or false nil 0)
(or false nil)
(not false)

Open in playground →

Remember:

  • and returns the first falsy value, or the last value if all are truthy.
  • or returns the first truthy value, or false if none are truthy.

Repair expression arity:

(lerp 0 10)

Open in playground →

lerp needs three arguments:

(lerp 0 10 0.5)

Open in playground →

Compare positional vs labeled — clamp declares :x :lo :hi, so both calls below are valid and produce the same value:

(clamp 1.5 0 1)
(clamp :x 1.5 :lo 0 :hi 1)
(clamp :hi 1 :x 1.5 :lo 0)

Open in playground →

A function without declared labels (+, here variadic) continues to reject the kvpair form:

(+ :a 1 :b 2) ; rejected

Open in playground →

Repair typed expression arguments:

(vec3 1 "two" 3)

Open in playground →

vec3 takes three numbers:

(vec3 1 2 3)

Open in playground →

Mastery Check

  1. Can a safe expression appear as a vector element?

  2. Why is (lerp 0 :to 10 0.5) invalid?

  3. Why can (vec3 1 "x" 3) fail validation before evaluation?

  4. What does (or false nil) return?

  5. What values are falsy in safe expressions?