Skip to content

Safe expressions

Paying the Debt from Orientation

In Orientation I put this in front of you and refused to explain it:

(camera :zoom (* 2 4))

Open in playground →

I said (* 2 4) is a form and nothing more, and that it becomes an expression only when the active schema says so. Here is the full version.

An expression is a form whose head has been declared an expression function by the schema that is active when the document is validated. Nothing about the source text makes a form an expression. (* 2 4) sitting in a document validated against a schema with no * in it is a form whose head is the symbol *, and it will be reported as an unknown head, not evaluated.

That indirection is doing real work. It means the set of things a document can compute is a closed list chosen by the host, in advance, in code. There is no way for a document to add to it: no import, no definition form, no eval. Add to that the properties the evaluator guarantees and you get the whole safety story:

no I/O a document cannot read a file, open a socket, or read the clock
no mutation evaluating a value cannot change another value
no recursion a call cannot re-enter itself, so it cannot fail to terminate
no side effects the same inputs give the same output, every time

Which is why I could promise in Orientation that reading a SJON file costs what reading data costs. It is not that the arithmetic is cheap. It is that the arithmetic cannot reach anything.

Where an Expression May Sit

Anywhere a value may sit, which by now you know is three places:

(camera :zoom (* 2 4)) ; the value half of a kvpair
(canvas :size [(* 2 960) 1080]) ; an element of a vector
(group (lerp 0 1 t)) ; a positional child

Open in playground →

The vector case is the one people forget. Numbers, units, vectors said vector elements are values and that forms are values, and this is what that adds up to: an expression inside a vector sits next to literals and is not special.

Calling Conventions

Arguments are positional by default:

(lerp 0 10 0.5)

Open in playground →

Functions that declare parameter names also accept a Swift-style labelled call form, where the 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) ; labelled, same call
(lerp :t 0.5 :from 0 :to 10) ; labelled and reordered, same call

Open in playground →

A labelled call is all or nothing. Half-and-half is a hard error:

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

Open in playground →

I chose that rather than trying to fill the gaps, because the moment you allow mixing you have to define what (lerp 0 :t 0.5) means, and every answer to that is a rule somebody has to remember.

Functions that declare no labels keep rejecting kvpairs outright, since there are no named slots for the labels to refer to:

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

Open in playground →

For labelled functions the validator separates the three ways a call can go wrong, and each has its own diagnostic code: an unknown label, a duplicate label, and a missing label.

What the Validator Can Check Before Evaluating

Some expression functions declare typed signatures, so obvious literal mistakes are caught before anything runs:

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

Open in playground →

But the validator only sees the document, so it has to know when to keep quiet. A symbol could come from a let binding or from the host, so a symbol argument always defers to runtime. A nested form is classified right there at validate time: if the inner expression declares a :result, that result is compared against what the outer slot expects.

(let [r 0.5]
(vec3 r r r)) ; `r` is opaque, so this validates clean.
(+ (vec3 1 2 3) 1) ; `vec3` declares `:result vector`; `+` wants a
; number, so this is expr_type_mismatch at arg 0.
(+ 1 (let [r 1] r)) ; `let` declares no result, so this defers.

Open in playground →

Read the middle line again, because it is the interesting one: the validator caught a type error in an expression it never evaluated, purely from the declared result of vec3. And read the third: let is opaque, and the validator says nothing rather than guessing.

Typed signatures do not make SJON statically typed. They are an aid for heads that advertise their shapes. Opaque and polymorphic heads still check arity first and leave the rest to evaluation.

One Thing Forms and Keyword Pairing Bought You

Keywords inside an expression are worth a paragraph, because the rule from Forms and keyword pairing turns out to do something useful here:

(= mode :ortho)

Open in playground →

:ortho cannot pair with anything, because the frame closes immediately after it. The greedy rule promotes it to a positional, and inside an expression a bare keyword evaluates to a keyword value, so = compares two keywords and the comparison reads exactly as written. There is no way to write a :k v argument inside an expression form, which is why expr_kvpair_not_allowed exists at all.

Worked Example

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

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, which is the whole list:

  • 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 them as (pi) and (tau).
  • Smoothing (WGSL semantics): 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 short enough to memorise: false and nil are falsy, and everything else is truthy. That includes 0, "", and [], which is where people coming from JavaScript get caught.

Domain Errors Come Back as NaN

(sqrt -1), (asin 2), and (pow -1 0.5) return IEEE 754 NaN instead of raising. I picked that deliberately: every host then produces the same bits for the same bad input, and a NaN travels through nested expressions without every function needing a special case for failure. The cost is that a domain error is quiet, so when you want the check, write it:

(if (>= x 0) (sqrt x) 0)

Open in playground →

Randomness You Can Reproduce

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 gives 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 such as a scene id, and key to walk a sequence of independent draws. Integer seeds are what I would use, though (rand01 1 0) and (rand01 1.0 0.0) are guaranteed to produce identical streams.

Exercises

Evaluate these by hand before you run them. + and * take any number of arguments, - negates one argument and subtracts more than one, / takes at least two and folds left, and mod takes exactly two:

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

Open in playground →

Predict the truthiness results. Remember that and returns the first falsy value, or the last value if all of them are truthy, and or returns the first truthy value, or false if none are:

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

Open in playground →

Repair the arity:

(lerp 0 10)

Open in playground →

lerp takes three arguments:

(lerp 0 10 0.5)

Open in playground →

Compare the calling conventions. clamp declares :x :lo :hi, so all three of these are the same call:

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

Open in playground →

And +, which declares no labels, keeps refusing:

(+ :a 1 :b 2) ; rejected

Open in playground →

Repair the typed argument:

(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?