- The Six Problems of X-Scripts
- Chapter 1: No Null
- Chapter 2: Ownership (Mutation You Can See)
- Chapter 3: Errors in the Return Type
- Chapter 4: Effects (One Model for Everything Impure)
- Chapter 5: Row Polymorphism (Duck Typing with Proofs)
- Chapter 6: Typed WASM (Guarantees at Runtime)
- Chapter 7: Faces In, Backends Out
- Chapter 8: What Every Backend Gets vs. What Some Get
- Chapter 9: The Co-processor and the Integer
- Putting It Together: A Complete Program
- What to Read Next
- Faces: Meeting Learners Where They Are
- Appendix A: Appendix: Bringing in Target Intrinsics
- Appendix B: Revision History
|
Note
|
Status: canonical, living document. This guide is the design reference for AffineScript itself and for any migration into AffineScript. Edits are welcome; record them in the Revision History appendix so the timeline is visible to future readers and AI agents. For systematic translation patterns from ReScript, TypeScript, or another |
The Frontier Guide takes you from "I write JavaScript / Python" to "I understand why AffineScript’s design choices produce better programs." It is not a reference manual — it is an unveiling.
Each section begins by naming a problem you already know, then shows exactly how AffineScript solves it, then gives you something to try.
Before AffineScript, there were many "improved" JS variants. Most of them fixed one or two problems and ignored the rest. AffineScript’s thesis is that all six of these problems have the same root cause: the runtime does not know the program’s intent — so neither does the programmer.
| Problem | Symptom | AffineScript answer |
|---|---|---|
Null is a lie |
Two nothings: |
|
Mutation is invisible |
Every variable is secretly mutable |
|
Errors throw sideways |
Untyped |
|
Async is bolted on |
Callbacks → Promises → async/await → signals |
Algebraic effects — one composable model |
Types become unreadable |
TypeScript |
Row polymorphism — structural, principled, readable |
Runtime is still JS |
Type erasure — guarantees vanish at run time |
Typed WASM (WasmGC) — types survive into execution |
Let us unveil each one.
JavaScript gives you two ways to say "nothing": null and undefined.
They behave differently. TypeScript adds T | null | undefined which
proliferates at every call site.
AffineScript has one nothing.
// Option[T] — present or absent, not three states:
let name: Option[String] = Some("Alice")
let missing: Option[String] = None
// Pattern matching forces you to handle both cases:
fn greet(name: Option[String]) -> String {
match name {
Some(n) => "Hello, " ++ n ++ "!"
None => "Hello, stranger!"
}
}The compiler rejects any code that uses a Some without matching on None.
There is no way to accidentally treat None as a value.
In JavaScript, every object is a shared mutable reference by default. You pass a value to a function; the function might mutate it; you have no idea. You copy defensively. You introduce bugs through aliasing.
AffineScript’s type system makes mutation visible in the type:
type Buffer = own { bytes: Array[Int], length: Int }
// ref: read-only borrow — the caller keeps the buffer
fn size(buf: ref Buffer) -> Int {
buf.length
}
// mut: mutable borrow — modifies in place, caller sees the change
fn append(buf: mut Buffer, byte: Int) -> () {
buf.bytes = buf.bytes ++ [byte];
buf.length = buf.length + 1;
}
// own: transfers ownership — caller cannot use buf after this call
fn into_bytes(buf: own Buffer) -> Array[Int] {
buf.bytes
}The compiler tracks usage automatically. If you pass buf to into_bytes,
any later use of buf is a compile-time error. No runtime check needed.
|
Note
|
Where the keyword goes. Notice that
When porting Rust-shaped code, the |
For fine-grained control, the underlying quantity annotations are available:
// @linear: must be used exactly once
fn consume_once(@linear handle: Int) -> () { () }
// @erased: compile-time only (proof terms, type-class witnesses)
fn trust_me(@erased _proof: Bool) -> String { "I proved it" }
// @unrestricted: unlimited use (the default for all values)
fn read_many(@unrestricted n: Int) -> Int { n + n }JavaScript throw can happen from anywhere. TypeScript does not check it.
You write try/catch and hope.
AffineScript errors live in the return type:
// The function signature tells you everything:
fn parse_int(s: ref String) -> Result[Int, String] {
match s {
"0" => Ok(0)
"1" => Ok(1)
_ => Err("not an integer: " ++ s)
}
}
// You must handle both cases — the compiler enforces it:
fn double_parsed(s: ref String) -> Result[Int, String] {
match parse_int(s) {
Ok(n) => Ok(n * 2)
Err(e) => Err(e)
}
}
// The `?` operator propagates errors up automatically:
fn double_parsed_short(s: ref String) -> Result[Int, String] {
let n = parse_int(s)?;
Ok(n * 2)
}There is no hidden throw path. Reading the signature tells you what can go wrong.
JavaScript’s async history: callbacks (2009), Promises (2012), async/await (2017), React Hooks (2019), signals (2023-). Each layer partially fixes the previous one. They do not compose.
AffineScript uses algebraic effects — one model for all of this:
// Declare what the effect offers:
effect IO {
fn println(s: String);
fn read_line() -> String;
}
effect Async {
fn await[T](promise: Promise[T]) -> T;
fn spawn[T](f: () -> T) -> Promise[T];
}
effect State[S] {
fn get() -> S;
fn put(s: S) -> ();
}
// Declare which effects a function uses — no surprise impurity:
fn echo() -> () / IO {
let line = IO.read_line();
IO.println("You said: " ++ line)
}
// Compose effects naturally — they are rows:
fn fetch_and_log(url: ref String) -> Result[String, String] / IO + Async {
let response = Async.await(http_get(url));
IO.println("fetched " ++ url);
Ok(response)
}Because effects are types, the compiler rejects code that uses IO in a context where IO was not declared. No accidental side effects.
TypeScript’s structural typing is powerful but the inference gets complex. AffineScript’s row polymorphism is principled and readable:
// This function works on any record that has at least `name: String`.
// Whatever other fields it has (captured by `..r`) are passed through.
fn greet[..r](entity: {name: String, ..r}) -> String {
"Hello, " ++ entity.name ++ "!"
}
type Person = {name: String, age: Int}
type Company = {name: String, employees: Int, revenue: Float}
// Both work — the row variable absorbs the extra fields:
greet(Person #{ name: "Alice", age: 30 })
greet(Company #{ name: "Acme", employees: 100, revenue: 9999.0 })Row polymorphism is also how effects compose:
// This function accepts any function that uses at least IO,
// plus any additional effects it may use (`..e`):
fn run_logged[..e](f: () -> () / IO + ..e) -> () / IO + ..e {
IO.println("start");
f();
IO.println("end");
}Other languages let you forget which numeric type you’re in. JavaScript’s
` is overloaded across numbers and strings; Python's ` polymorphises
across int, float, Decimal. AffineScript makes you commit — but
only on the lhs — and the rhs and result follow.
// lhs is Float → rhs is checked as Float, result is Float:
fn scale(x: Float, k: Float) -> Float { x * k + 1.5 }
// lhs is Int → rhs is checked as Int, result is Int:
fn step(n: Int) -> Int { n * 2 + 1 }
// Mixed Int/Float — type error, no coercion:
fn bad(x: Float, n: Int) -> Float { x + n } // ERROR: Float vs IntThe rule: arithmetic and comparison operators (+, -, *, /, %,
<, ⇐, >, >=) synthesise the lhs type first. If it’s Float, the
rhs is checked against Float. Otherwise the operator is monomorphic at
Int×Int → Int. There is no implicit promotion and no typeclass
dispatch. If you want to mix, convert explicitly.
This earns its keep in two ways. First, error messages name a single
concrete type instead of a tyvar; you read the failure on the offending
line. Second, the lowering to every backend (WASM, JS, C, Faust, WGSL,
…) emits the right per-type instruction without needing dictionary
passing: add becomes i64.add or fadd, never both.
TypeScript erases all its types when it compiles to JavaScript. The runtime has no idea what you proved. You rely on defensive checks, branded types, and faith.
AffineScript targets Typed WebAssembly (WasmGC). The type guarantees survive into the binary. The WASM runtime enforces them.
This means: - Your struct layout is what you declared — no fields added at runtime. - Your variant tags are what the compiler assigned — no stringly-typed dispatch. - Linear memory is managed by the AffineScript runtime, not the JS GC. - Your ownership invariants hold at the machine level, not just in the source.
# Compile to standard WASM:
affinescript compile hello.affine --output hello.wasm
# Compile to WasmGC (struct.new, array.new_fixed, typed references):
affinescript compile-gc hello.affine --output hello.wasm
# Run in Deno (which supports WasmGC natively):
deno run --allow-read runner.jsAffineScript reaches users through two orthogonal axes. Reading the documentation of either alone leads to the wrong assumption that they compose. They don’t. The diagram is:
[ Python source ] [ JS source ] [ Lucid source ] [ canonical .affine ]
\ | | /
─────────► AffineScript AST ◄──────────────
|
┌──────┬──────┬────┴────┬──────┬──────┬─ ─ ─
WASM JS C WGSL Faust ONNX ...
Faces are input skins. A face is a parser front-end: Python-shaped
syntax, JavaScript-shaped syntax, pseudocode, PureScript-shaped Lucid,
CoffeeScript-shaped Cafe. The --face flag picks the parser; the AST
that comes out the other side is canonical AffineScript. Error messages
are translated back into the face’s vocabulary, so a Python-face user
sees def, True, indentation, not fn, true, {}.
Backends are output skins. A backend is a code generator: WASM,
WASM-GC, Julia, JavaScript, C, Rust, OCaml, Lua, Bash, Gleam, ReScript,
Nickel, WGSL (compute shaders), Faust (DSP), ONNX (tensor graphs),
LLVM IR, Verilog, … The output extension picks the backend
(-o foo.js, -o foo.wgsl, -o foo.onnx, …).
The two axes don’t compose: there is no "JS face implies JS output" or "Python face implies Python output." You can write Python-faced source and compile it to WGSL; you can write canonical AffineScript and compile it to ReScript. The face you read in is independent from the target you write out.
|
Note
|
Why this matters for design. Once you internalise the orthogonality,
the whole |
The backend list is long, but not every backend accepts every program. Backends are tiered by how much of the source language they take. The tier is part of the user contract, not a limitation to be apologised for — kernel sublanguages are restricted on purpose, because their target runtimes are restricted.
| Tier | Members | Acceptance |
|---|---|---|
A — host-language emitters |
WASM 1.0, WASM-GC, Julia, JS, C, Rust, OCaml, ReScript, Lua, Bash, Gleam, Nickel |
Full AffineScript surface. Anything you can write in canonical AS, these accept. Missing features in the codegen produce a loud error at compile time, never silent miscompilation. |
B — IR / native |
LLVM IR (Cranelift planned) |
Full surface, lower level of abstraction. Some passes (closures, traits) may need lowering that doesn’t exist yet. From LLVM IR, |
C — kernel sublanguages |
WGSL (compute shaders), SPIR-V (planned), CUDA / Metal / OpenCL (planned), Faust (DSP), ONNX (tensor graphs), MLIR/StableHLO (planned) |
Deliberately restricted subset. No |
A program that compiles to Tier A may not compile to Tier C. That is by
design: you wouldn’t expect match over enum variants to work on a GPU
shader, and the WGSL backend’s job is to tell you that explicitly rather
than emit shader code that misbehaves at runtime.
A practical implication for translators: identify your target’s tier before you start re-decomposing. If you’re aiming at WGSL, you’re writing kernel-shaped code from the first line; sum types and effects won’t be available, and that should drive the design.
You already know the problem, even if you have not named it. You have a working ReScript or JavaScript program — a game, a simulator, a tool — and you want the logic to run with AffineScript’s guarantees, compiled to wasm, without rewriting the rendering, the input handling, or the network. The instinct is to port the whole thing. The better move is to split it.
AffineScript is the brain; JS/Pixi are the senses. The wasm core does logic, physics, decisions, state machines; the host does rendering, input, strings, and dispatch. The two halves communicate by passing raw primitives — Ints, Floats, Arrays — across the wasm boundary. Nothing stringly-typed crosses; nothing stateful crosses; only numbers.
This chapter unveils the mental model. The systematic, verified re-decomposition patterns live in the Migration Playbook’s co-processor section — read that when you are actually carrying a codebase across.
The keystone idea: a value drawn from a small, named domain — a security
rank Open/Weak/Medium/Strong, a device-port name, a tile kind — is
encoded to a canonical integer on the host side, and the wasm core
computes on that integer. The host keeps the names; the core gets the code.
// The encoding IS the rank. 0 = Open, 1 = Weak, 2 = Medium, 3 = Strong.
// The host owns "Open" <-> 0; this core owns the arithmetic.
pub fn rank_security_level(v: Int) -> Int {
if v < 0 { 0 } else { if v > 3 { 3 } else { v } }
}
pub fn passes_min_security(actual: Int, required: Int) -> Bool {
rank_security_level(actual) >= rank_security_level(required)
}There is no String here, and no sum type crossing the boundary. The four
rank names never enter wasm; only their codes do. Compiled host-native,
this is a tiny module — in idaptik’s case 454 bytes, whose only WASI
import is fd_write. Every decision the host used to phrase in terms of
rank names is re-phrased as integer arithmetic on rank codes. That is
the whole pattern, and its slogan is "the integer IS the X."
Three things fall out of the split that a full rewrite would not give you:
-
The boundary is narrow and auditable. Only primitives cross. You can enumerate exactly what passes between brain and senses, because it is a short list of Ints and Floats — not an open-ended graph of objects.
-
The core is small and verifiable. A pure-integer kernel has no strings to allocate, no state to corrupt, no effects to handle. It compiles to a handful of kilobytes and its behaviour is a function of its inputs — which is exactly what makes differential parity (below) a usable acceptance test.
-
The senses stay where they are good. Rendering, input, and the DOM remain in the host, which already does them well. You are not reimplementing Pixi in AffineScript; you are giving Pixi a verified brain to call.
|
Note
|
This is the operational form of the WASM Co-processor Model described in the Frontier Programming Practices guide. That guide states the posture; this chapter and the Playbook show you how to port into it. |
A co-processor port has a crisp acceptance bar, and it is not "the compiler accepted it." It is two conditions, both required:
-
Build-green — the
.affinecompiles to wasm, host-native (affinescript compile FILE -o OUT.wasm). -
Parity-green — the wasm output matches an independent oracle — a separate implementation written from the contract, not from the
.affine— across a sweep of inputs, including out-of-band ones.
idaptik’s SecurityRank core cleared this bar at 78/78 against a JS
oracle of the documented 0..3 contract: every input, in-band and
out-of-band, agreed. Build-green alone would only have proved the compiler
ran; parity-green proves the kernel is right.
The co-processor model is sharp because its limits are sharp, and the limits are teaching points, not apologies:
-
Strings have an ABI, so avoid crossing them. When a string genuinely must cross, there is a real wire format (
[len: i32 little-endian][utf8]) and an exported allocator to honour — see the Playbook. The cheapest way to handle a string at the boundary is to keep it host-side and send a code instead (the integerization above). Sometimes the data was never a string — idaptik’sKernel_IOdoes a sandbox prefix check on bytes that are already anIntarray, so it compares integer arrays directly and never builds aStringat all. -
No code runs at module load. AffineScript modules have no load-time side effect to hook. ReScript that mutates a module-level cell, reads
Date.now(), or callsConsole.logambiently has nowhere to put that after the port — those capabilities must be threaded in as parameters (or supplied byexternhost functions) until effect-handling codegen lands. A kernel that relies on module-load effects will not compile to wasm today; that is the compiler telling you to make the dependency explicit. -
The faithfulness is provable. "The integer IS the X" is not folklore — it is an encoding-faithfulness theorem, machine-checked in
EchoEncodingFaithfulness.agda. An injective encoder is lossless; a clamp is provable, localised loss with no decoder. SecurityRank’s clamp is exactly that certified hazard — and which direction it clamps (garbage up toStrongis fail-open; garbage down toOpenis fail-closed) is a security decision the proof surfaces but does not make for you.
Sketch the boundary for a different domain before reading the Playbook.
Suppose the host has tile kinds Empty | Wall | Door | Water:
-
Choose the integer encoding (
Empty = 0,Wall = 1, …). Write it down — that table is the contract. -
Write a pure-integer AffineScript function
is_passable(tile: Int) → Boolthat the host can call per tile. NoString, no sum type crosses. -
Name one out-of-band input (a negative code, or
99) and decide — as a game-design question — whatis_passableshould return for it, and whether that default is fail-open or fail-closed.
You have just done the three hardest parts of a co-processor port: the encoding, the pure kernel, and the out-of-band policy. The Playbook turns the sketch into a compiled, parity-checked module.
// complete_example.affine — a small resource manager
effect IO {
fn println(s: String);
}
effect Exn[E] {
fn throw(err: E) -> Never;
}
type Config = { max_retries: Int, timeout: Int }
type Request = own { url: String, config: ref Config }
type Response = { body: String, status: Int }
type FetchError = { message: String }
// Open a request (produces a linear resource):
fn make_request(url: String, config: ref Config) -> own Request {
Request #{ url: url, config: config }
}
// Send the request (consumes it — can't send twice):
fn send(req: own Request) -> Result[Response, FetchError] / IO {
IO.println("Sending request to " ++ req.url);
// Real implementation would use the Async effect
Ok(Response #{ body: "OK", status: 200 })
}
// The return type tells you: this can fail, and it does IO.
fn fetch_with_retry(
url: String,
config: ref Config
) -> Result[String, FetchError] / IO {
let req = make_request(url, config);
match send(req) {
Ok(resp) =>
if resp.status == 200 {
Ok(resp.body)
} else {
Err({ message: "HTTP " ++ Int.to_string(resp.status) })
}
Err(e) => Err(e)
}
}-
Warmup scripts — hands-on exercises, 15 minutes each
-
Migration Playbook — systematic re-decomposition rules for porting ReScript / TypeScript / other
-scriptcodebases into AffineScript -
Frontier Programming Practices — the wider design philosophy (v2.0, 2026-04-10)
-
Language Specification — the authoritative grammar and semantics
-
Settled Decisions — architectural choices and why they were made
-
Design Vision — the long view
If canonical AffineScript syntax feels unfamiliar, the --face flag lets
you write in a surface syntax closer to what you already know:
# Python-style syntax — def, True/False/None, indentation:
affinescript --face python check my_program.py
# Preview what the preprocessor produces from a Python-face file:
affinescript preview-python-transform my_program.pyRegardless of which face you use, the type checker sees canonical AffineScript. Error messages are translated back to the face you chose, so "Ownership error: single-use variable" appears instead of internal jargon.
Additional faces (JS-face, Pseudocode-face, and others) are on the roadmap. See faces.adoc for the architecture.
Backends often need to call runtime ops the AffineScript resolver doesn’t know about — GPU shuffle intrinsics, ONNX operators, DSP primitives, hardware-simulation tasks. You don’t need to extend the language for these. The pattern is declare-then-recognise:
-
Declare the op as a stub function in your source file with the right signature.
-
The resolver and typechecker accept the stub as a normal function.
-
The backend codegen pattern-matches the stub’s name (case-insensitive where appropriate) and emits the target’s intrinsic.
-
The stub’s body is irrelevant — it’s a placeholder so the AS frontend has something to bind. Use any value-shape that returns a sensible default.
// Stubs for ONNX operators. Bodies are placeholders; the ONNX codegen
// recognises `relu` → ONNX op `Relu` and ignores what follows the `=`.
fn relu(x: Array[Float]) -> Array[Float] { x }
fn add(a: Array[Float], b: Array[Float]) -> Array[Float] { a }
fn graph(x: Array[Float], y: Array[Float]) -> Array[Float] {
let s: Array[Float] = add(x, y);
let r: Array[Float] = relu(s);
r
}affinescript compile graph.affine -o graph.onnx produces a real ONNX
ModelProto containing Add and Relu nodes. The codegen ignored the
stub bodies and used the names. The same pattern applies to every Tier-C
kernel-sublanguage backend: CUDA’s __shfl_xor, Metal’s metal::float4,
Faust’s tanh, Verilog’s $display, and so on.
Why this is preferable to extending the language: each backend stays self-contained. The frontend remains target-agnostic. Stubs live in the user’s source where they’re visible at the call site, rather than in a hidden compiler-internal table that drifts as targets evolve. And when a new target ships, its intrinsic vocabulary is just whatever names its codegen recognises — a property the user can read off from the backend’s documentation and reproduce locally.
This document is intended to evolve. When you change it — adding a chapter, sharpening an example, retiring an obsolete claim — record the change here so that downstream readers (especially AI agents loading the guide at session start) can see what has moved.
| Revision | Date | Notes |
|---|---|---|
1.0 |
2026-04-11 |
Initial unveiling. The six X-Script problems and AffineScript’s answers; chapters 1–6; complete-program example; faces. |
1.1 |
2026-05-02 |
Findings from the May-2026 backend buildout (16 new backends shipped: JS, C, WGSL, Faust, ONNX, OCaml, Lua, Bash, Nickel, ReScript, Rust, LLVM, Verilog, Gleam, plus protobuf and onnx_proto support modules) folded back into the guide:
(1) Chapter 2 gains a "where the keyword goes" sidebar — |
1.2 |
2026-06-04 |
New Chapter 9 — The Co-processor and the Integer — unveils the ReScript → AffineScript → wasm co-processor mental model ("AffineScript is the brain; JS/Pixi the senses; only primitives cross the boundary") and the "the integer IS the X" encoding idea, grounded in idaptik’s verified 454-byte |