- 1. The Thesis
- 2. What’s In the Box
- 3. What’s Deliberately Not in the Box
- 4. The Type System in Practice
- 5. Idiomatic Patterns
- 5.1. Type-State Machines (phantom + affine)
- 5.2. Capability Types (phantom)
- 5.3. Branded Identifiers (phantom + nominal aliases)
- 5.4. Trait-Like Dispatch (records + rows)
- 5.5. Purity Tagging (phantom + effect rows)
- 5.6. Resource Pools (affine + higher-order functions)
- 5.7. Refinements in Argument Positions
- 5.8. Migrating from -script Languages
- 6. The Compilation Target
- 7. Faces: Frontend Surfaces
- 7.1. Faces Are Brand Surfaces, Not Compiler Forks
- 7.2. Identity Belongs in Content, Not in the Filesystem
- 7.3. Pure Text-to-Text Transformers Need Snapshot Tests Plus Round-Trip Parse
- 7.4. Examples Are Tests, Not Demonstrations
- 7.5. Error-Vocabulary Cost Scales as O(faces × error_kinds)
- 7.6. Span Fidelity Has a UX Cost
- 8. Design Principles
- 9. Getting Help
- 10. The Typed WASM Project
|
Note
|
This guide supersedes the v1.0 defensive draft. It describes what AffineScript is and how to use it well, rather than warning what not to do. Its machine-readable companion is |
AffineScript is JavaScript-ergonomic correctness.
It takes the developer experience of the -script family — TypeScript, ReScript, PureScript — and raises the floor on correctness through a small, deliberately chosen set of advanced types. It compiles to Typed WASM for performance, portability, and safety.
The design principle is ergonomics first: the type system’s defaults are sensible, so most code reads like modern JavaScript. Correctness is enforced at compile time rather than hoped for at runtime.
The language is small on purpose. Every feature in it was chosen for a specific reason, after looking at what the rest of the -script family already does well and what nobody in that family has tried yet.
AffineScript ships with eight type-system features, grouped by role:
These are features that other sound -script languages already have. AffineScript copies them because they work.
-
Phantom types — types that exist only at compile time. Distinguish
UserIdfromProductIdso you can’t mix them up by accident. -
Immutable by default —
let x = 5binds a value, not a variable. Uselet mutwhen you want mutation. Matchesconstin modern JavaScript. -
Row polymorphism — functions operate on any record containing the fields they need. No inheritance hierarchy required.
-
Full type inference — types are inferred; you rarely annotate them. You do annotate quantities (the affine part) at function signatures, but not inside function bodies.
-
Sound type system — if the type checker says your code is fine, it really is. No
any, no unsafe escape hatches, no runtime surprises from type mistakes. -
Effect tracking — functions declare which effects they perform (IO, mutation, failure). Callers see at a glance what a function can do.
-
Affine types — values that can be used at most once. Perfect for resources: file handles, network sockets, unique game entities, exchange tokens. "Use after close" becomes a compile error, not a runtime crash.
Because AffineScript aims at ergonomic correctness — not research-grade expressiveness — several powerful features are left out on purpose:
-
Linear (strict exactly-once) types — subsumed by affine. Use affine instead.
-
Full dependent types — too expensive for daily use. They live in the sibling Typed WASM project, not here.
-
Tropical types, units of measure, shape-indexed vectors — useful, but they belong in Typed WASM where the numeric hierarchy and decidable type-level arithmetic live.
-
Full algebraic effect handlers — effect tracking is in; effect handling (intercepting and redirecting effects at runtime with multi-shot resume) is deferred until its interaction with affine types is resolved.
-
Unbounded refinement — refinement is restricted to what the compiler can prove without calling an external solver.
If you want any of those, the Typed WASM project is where they live or will live. AffineScript compiles to Typed WASM, so the two projects are complementary, not competing.
Values you want to use at most once:
fn open(path: String): File
fn read(f: File): (File, String) // returns the file back so you can keep using it
fn close(f: File): Unit // consumes the file; cannot be used after
fn good(path: String): String {
let f = open(path);
let (f, contents) = read(f);
close(f);
contents
}
fn bad(path: String): String {
let f = open(path);
let (_, contents) = read(f);
close(f); // COMPILE ERROR: f was already consumed by read
contents
}The compiler tracks resource lifetimes and refuses to compile code that uses a file after it’s been consumed or forgets to close it.
Functions operate on any record containing the fields they need, without inheritance or classes:
fn damage(target: {health: Int, ..rest}, amount: Int): {health: Int, ..rest} {
{ ..target, health: target.health - amount }
}
let player = {health: 100, name: "Alice", level: 5};
let damaged = damage(player, 10);
// damaged has type {health: Int, name: String, level: Int}
// The function doesn't know or care about `name` and `level` — it passes them through...rest is a row variable: "the rest of the fields, whatever they are." The compiler infers it and threads it through.
Bake invariants into types so the compiler enforces them:
type PositiveInt = Int where (x >= 0);
fn heal(h: PositiveInt, delta: PositiveInt): PositiveInt {
h + delta
}
let hp: PositiveInt = 100;
let after = heal(hp, 25); // ok
let broken = heal(hp, -10); // COMPILE ERROR: -10 is not PositiveIntRefinements are restricted to the decidable fragment (arithmetic, comparisons, boolean combinations) so the compiler can prove things without needing an SMT solver. If you write a refinement the compiler can’t decide, it tells you so and asks you to narrow it.
Use the type system to distinguish values that look the same at runtime:
type UserId = Int;
type ProductId = Int;
fn fetchUser(id: UserId): User
fn fetchProduct(id: ProductId): Product
let u: UserId = 42;
let p: ProductId = 99;
fetchUser(u); // ok
fetchUser(p); // COMPILE ERROR: expected UserId, got ProductIdZero runtime cost. The distinction exists only in the type checker. A Rust user would recognise this as "newtype"; a Haskell user as "phantom"; a TypeScript user as "branded types done properly."
let binds an immutable value. let mut binds a mutable one.
let x = 5;
x = 6; // COMPILE ERROR: x is immutable
let mut counter = 0;
counter = counter + 1; // okMatches const in JavaScript and let in ReScript — familiar to most developers.
Functions declare what effects they can perform:
fn pureCompute(x: Int): Int // no effect row = pure
fn readFile(path: String): String / {IO} // may perform IO
fn maybeParse(s: String): Int / {Fail} // may fail
fn runScript(path: String): Int / {IO, Fail} // bothCallers see at a glance what a function can do. The compiler tracks effect propagation so a "pure" function can’t accidentally call an impure one without declaring it.
|
Note
|
Effect tracking is in. Effect handling (intercepting effects at runtime) is not — that’s a separate design problem that hasn’t been solved in a way that’s compatible with affine types. |
AffineScript has a small feature set chosen carefully. Many advanced type-level patterns emerge for free by combining existing features — you don’t need to wait for the compiler to add them.
Model state transitions at the type level:
type Connection<state>;
type Open;
type Closed;
fn connect(addr: String): Connection<Open>
fn send(c: Connection<Open>, data: String): Connection<Open>
fn close(c: Connection<Open>): Connection<Closed>
// Compiler refuses to send() on a Connection<Closed>.
// Compiler refuses to close() twice because Connection<Open> is consumed.This is the resource-lifecycle pattern from the old guide, expressed cleanly using two in-scope features.
Tag values with what they’re authorized to do:
type Token<cap>;
type Admin;
type User;
fn downgrade(t: Token<Admin>): Token<User>
fn dangerousOp(t: Token<Admin>): Result
fn safeOp(t: Token<User>): ResultCallers must hold the right capability. Downgrades are visible in the type signature.
Avoid mixing up identifiers that share a runtime type:
type UserId = Int;
type OrderId = Int;
type Email = String;
// Cannot accidentally pass a UserId where an OrderId is expected.AffineScript doesn’t have type classes, but you can build equivalent functionality using records and row polymorphism:
type Eq<a> = {eq: (a, a) -> Bool};
fn contains<a>(dict: Eq<a>, list: List<a>, target: a): Bool {
// uses dict.eq to compare elements
}
let intEq: Eq<Int> = {eq: \(a, b) -> a == b};
contains(intEq, [1, 2, 3], 2); // trueDictionary passing gives you overloading and type-class-like dispatch without the compiler needing to support them natively.
Use effects as compile-time tags for purity:
fn pureCompute(x: Int): Int { x * 2 } // no effect row, pure
fn ioOp(): String / {IO} { ... } // tagged IOA function without an effect row is pure. Tests can rely on pure functions being deterministic. Impure functions can’t be called from pure contexts without propagating the effect.
Manage finite resources with compile-time checks:
fn withFile<r>(path: String, action: File -> (File, r)): r {
let f = open(path);
let (f, result) = action(f);
close(f);
result
}
let contents = withFile("data.txt", \f -> read(f));The caller never sees the raw File and can’t forget to close it.
|
Caution
|
Affine + async interaction
The example above is synchronous. Real-world code that consumes affine resources across
If your migration hits this, name it explicitly in the code (`// affine + async cancellation TBD\`) and consult the issue tracker before assuming a working pattern. |
Refinement types are most useful on caller-provided values: sizes, counts, indices, and bounded numerics that need to be valid before a function does anything with them.
type WindowConfig = {
minWidth: Int where (>= 320),
minHeight: Int where (>= 480),
background: String,
letterbox: Bool,
};
fn createWindow(cfg: WindowConfig): WindowCompare with the migration source:
~resizeOptions={
minWidth: 768.0,
minHeight: 1024.0,
letterbox: false,
},The ReScript form has no compile-time guard against being set to 0.0, -100.0, or 1.0 — only runtime checks (if any). Argument-position refinements catch this at the call site.
This is where 80% of refinement types' daily ergonomic value comes from: not invariants on internal state, but pre-conditions on caller input.
This chapter exists because the most-frequent migrator question is "I have ReScript / TypeScript / JavaScript code that does X — what does the AffineScript form look like?". The patterns below address the six anti-patterns most commonly encountered.
Symptom: a top-of-module sequence of let _ = SomeModule.value lines whose only purpose is to trigger module-load side effects (registry registration, plugin install, etc.).
Root cause: ReScript / JS module loading runs top-level code as a side effect of import. AffineScript compiles to wasm where there is no implicit module-load-time side effect.
Fix: move the registration into an explicit function call, invoked from your boot sequence:
fn registerScreens(engine: Engine) {
engine.screens.add(TitleScreen.constructor);
engine.screens.add(WorldMapScreen.constructor);
// ...
}Symptom: inline %raw (ReScript) or as any (TypeScript) blocks containing JS that pokes at the DOM, console, or browser globals.
Decision tree for the migration:
-
Is there a typed binding (
@affinescript/dom,@affinescript/pixijs, etc.) for the operation? Use it. -
Is the binding missing? Add it to the relevant binding package; do not inline a
%rawequivalent in your application code. -
Is the operation fundamentally untyped (e.g. interacting with a third-party JS library no one has bound)? Use a documented
Unsafe.embedescape with a comment naming why and tracking the binding-it-needs in an issue.
The first answer is by far the common case. The wrong move is to inline raw JS, which carries no type checking, no effect tracking, and no migration story to the eventual typed binding.
What %raw becomes in practice — three concrete examples from the idaptik Wave 3 pilot, where a Main.res with three distinct %raw blocks was migrated cleanly to migration/main/:
%raw use |
Replaced by | Mechanism |
|---|---|---|
|
|
|
Manual error-pane DOM injection via |
Typed DOM binding |
|
|
Structured |
|
All three translated without any Unsafe.embed (the step-3 escape hatch). The 80/20 outcome of step 1 — "use the typed binding" — holds: in practice, the binding either exists or one missing helper needs to be added once for the whole codebase.
Symptom: a single 100+-line function (often called init, start, or boot) that creates the engine, registers screens, wires multiplayer, sets up audio, and fires the first frame, all in one big async block.
Fix: decompose along step boundaries. Each step takes the affine resource, produces a Result, and the boot sequence threads them with ? (or >>=).
The decomposition is itself a Frontier-Guide-worthy pattern: small functions, each effect-tracked, each independently testable.
Symptom: a function that takes 8-12 named-argument lambdas as a callback bundle. Sub-cases hidden in 60+ lines of inline closures.
Fix: row-polymorphic handler record. Construct once, pass once.
type HandlerSet<rest> = {
onJoin: Player -> Unit / {IO},
onLeave: PlayerId -> Unit / {IO},
onMove: (PlayerId, Float, Float) -> Unit / {IO},
..rest,
};
fn makeDefault(): HandlerSet<{}> {
{ onJoin: ..., onLeave: ..., onMove: ... }
}The ..rest lets future handlers be added without churn at every call site.
Symptom: a single top-level Promise.catch(e ⇒ …) (or top-level try { … } catch (e: unknown)) that absorbs every async failure into one untyped handler. This pattern has come to be called the Promise umbrella — picture-clear: one thing on top, rains absorbed below.
The umbrella is lossy (every failure is the same opaque value), untyped (handler can’t dispatch on cause), encourages swallow-and-continue, and lifts errors away from their call site.
Fix: structured Result[StartupError, RunningGame] (or domain equivalent) where StartupError is a sum type naming each failure mode. The ? operator threads errors back through the call graph; the umbrella becomes a match on the structured error at the surface.
Symptom: MultiplayerGlobal.voice.onConnectionChange = Some(…) or similar — registering callbacks by mutating a global record.
Fix: pass the handler record as an argument at construction time (Anti-pattern 4’s solution applies). The type system can see the wiring; mutable globals are an effect the compiler can’t track.
AffineScript compiles to Typed WASM as its primary target. WASM gives you:
-
Near-native performance
-
Portability — browser, server, edge, embedded
-
Strong isolation — sandboxed by default
-
A stable, standardised, evolving platform
Typed WASM is a sibling project: it defines a richer type system on top of WebAssembly. AffineScript is one of its consumers, but Typed WASM is designed as a general-purpose compile target — other languages can use it too.
A Julia backend exists as a secondary target for scientific computing use cases where you want AffineScript’s type safety alongside Julia’s numeric ecosystem.
In the intended deployment, AffineScript serves as the "brain" — logic, physics, math, state machines — while the frontend (Gossamer, Deno, browser JS) serves as the "senses" — rendering, input, network. The two communicate by passing raw primitives (Ints, Floats, Arrays) across the WASM boundary.
┌────────────────────┐ primitives ┌────────────────────┐
│ Frontend (JS/TS) │ ←──────────→ │ AffineScript │
│ Gossamer / Deno │ over WASM │ logic + physics │
│ (rendering/input) │ boundary │ (brain) │
└────────────────────┘ └────────────────────┘The AffineScript side is where the correctness guarantees live: affine resource tracking, row-polymorphic domain models, refinement-typed invariants. The frontend side is where the ergonomics of JS are most useful: DOM, WebGL, input handling.
A face is a sugared surface syntax over the canonical AffineScript AST. AffineScript ships with six established faces — canonical, RattleScript (Python-like), JaffaScript (JS/TS-like), PseudoScript (pedagogy), LucidScript (PureScript/Haskell-like), and CafeScripto (CoffeeScript-like). The six rules below were earned during the May-2026 buildout of LucidScript and CafeScripto, and during the eject of the five non-canonical faces into their own brand-surface repos.
A face is a transformer (one OCaml module in lib/<name>_face.ml) plus a brand repo (README, examples, a thin shim that defaults --face). The brand repo carries no parser, no checker, no codegen. The compiler lives here, in affinescript, and there is exactly one of it.
Symptoms of forgetting this: the brand repo grows a Cargo.toml, a vendored copy of the canonical compiler, or a separate binary. Each of those is a divergence vector. When the canonical type checker gains a feature, every fork has to re-port it. After two forks, no two faces type-check the same program the same way.
The shim pattern (used by all five non-canonical face repos):
#!/usr/bin/env bash
if [ $# -eq 0 ]; then
exec affinescript
fi
subcmd="$1"
shift
exec affinescript "$subcmd" --face <face_name> "$@"That is the entire delivery surface. Anything more, and you have a fork.
Every face uses the canonical .affine extension. The face is selected by a face: pragma on the first comment line, or by --face NAME on the CLI. Per-face extensions (.rattle, .pyaff, .jsaff, .lucidaff, .cafeaff) were tried and abandoned.
Why the single extension wins:
-
Build tools, editors, and language servers configure once, not N times.
-
A file rename never changes its semantics.
-
Polyglot directories don’t need per-face glob patterns.
-
A face transformer is free to evolve its sugar without breaking file discovery.
The pragma is the source of truth. Extension dispatch is kept around as a deprecation path only.
A face transformer is a string-to-string function over source text. The cheap, high-signal test for it is a snapshot diff plus a round-trip parse:
-
Run
preview-<face>over an example file, capture stdout. -
Diff against
tests/faces/<base>.expected.txt. If the snapshot moves, you see exactly what changed. -
Parse the captured output as canonical. If parse fails, the transformer produced something the canonical grammar rejects.
The May-2026 buildout shipped this as tools/run_face_transformer_tests.sh with three modes — check (default), --update (rewrite snapshots), --record-missing (bootstrap). On its first run it caught five distinct transformer bugs across Lucid and Cafe that visual review had missed. None of the five would have been visible without the round-trip parse step.
The lesson generalises beyond faces: any pure text-to-text transformer in the codebase (formatter, codegen, source rewriter) deserves the same snapshot-plus-roundtrip harness.
Every file under examples/faces/ must round-trip clean through the snapshot test. If an example exercises a transformer gap and won’t round-trip, simplify the example until it does — or fix the transformer.
The discipline is what makes the face system honest. An example that breaks round-trip but ships in the README anyway is a documented falsehood. The README either matches the test suite, or one of them is lying.
When a real-world program hits a known transformer gap, that gap goes in the examples/faces/README.adoc "Known transformer gaps" list, with a one-line workaround. The example file itself uses the workaround. Users discover the gap through the docs, not through a confused parse error on their own code.
When a face is added, every error class the user can hit needs a face-appropriate phrasing. A Haskell-shaped surface should not say "borrow checker rejected this binding" — it should say "Linearity error" or "Could not match type". A CoffeeScript-shaped surface should not say "linearity violation in 'do' notation" because there is no do notation.
The codebase keeps this cost manageable by routing every user-facing error string through lib/face.ml’s dispatch table (`format_*_for_face functions). Adding a face means adding one arm per error kind. There is no shortcut: face-shaped vocabulary is part of the face contract.
If the dispatch table starts to lag the parser/checker, the face slowly leaks canonical-flavoured errors and the brand promise erodes. The fix is mechanical, not creative — keep the table in sync.
A face transformer rewrites the user’s source. The line numbers in any subsequent error message refer to the transformed canonical text, not the user’s original face source. This is a real UX regression compared to writing canonical directly.
Three honest options:
-
Document the regression in the face’s README under "Caveats".
-
Have the transformer record a span map from canonical to source, and have the diagnostic layer translate.
-
Build the face into the parser proper (cmdliner already supports
--face), which costs more in the parser and saves the span work.
The May-2026 buildout chose the first option for all five non-canonical faces, because they are alpha and the alternative is a substantial parser refactor. The choice is recorded honestly. Do not paper over it with phantom span improvements.
|
Note
|
Two further lessons from the May-2026 work — the adapt-then-commit pattern for ejecting an existing GitHub repo with history preserved, and the focused-add discipline for committing in a working tree that has unrelated dirty changes — are operational rather than language-level. They live in a separate playbook rather than in this scope document. |
Some powerful type-system features are deliberately excluded because they come at high ergonomic cost. The question we ask for every feature is: does it make correct code easier to write, or does it make the language harder to use? If the answer is the latter, it doesn’t go in.
Six of the eight features are copied from -script languages that have already proven the design in production. We’re not inventing new type-system machinery where existing machinery works. The novelty budget is spent on affine types, which no -script has.
We don’t add features because they might be useful someday. We add them when there’s a concrete use case that can’t be expressed any other way. Many patterns that look like "missing features" turn out to be expressible using existing features — see the Idiomatic Patterns section.
The scope of AffineScript lives in this document and its machine-readable companion AI.a2ml. Any feature not listed there is out of scope. If a collaborator (human, bot, or earlier-you) suggests adding a feature, the first question is: is it in the scope document? If not, the scope document is the source of truth — not the code.
This sounds bureaucratic; it isn’t. It’s the one discipline that stops a language from drifting into accidental complexity.
-
Scope question? Check
AI.a2ml(machine-readable) and this document. They are the source of truth. -
Confusing type error? The compiler tries to give source location, the offending usage, and a suggested fix. If an error is unhelpful, file it — error messages are part of the correctness story.
-
Pattern unclear? See the Idiomatic Patterns section. If your use case isn’t covered, propose it.
-
"Is this idiomatic?" If it uses in-scope features and compiles cleanly, it’s legitimate AffineScript. Idiomatic style emerges from practice; the patterns here are starting points, not the only ways.
If you need features that aren’t in AffineScript — dependent types, tropical types, units of measure, shape-indexed arrays — the Typed WASM project is where they live (or will live).
-
Typed WASM is a compile target, not a language. It provides a richer type layer on top of WebAssembly.
-
AffineScript compiles to Typed WASM.
-
Other languages can also compile to Typed WASM; it’s designed as a general-purpose target.
The split is deliberate: AffineScript is the ergonomic, correctness-focused surface language; Typed WASM is the powerful, language-agnostic type layer that any language can target.
|
Tip
|
If you find yourself wanting to add a research-grade type feature to AffineScript, stop and ask: does this belong in AffineScript, or in Typed WASM? The answer, most of the time, is Typed WASM. |