Skip to content

Latest commit

 

History

History
573 lines (381 loc) · 28.2 KB

File metadata and controls

573 lines (381 loc) · 28.2 KB

AffineScript: Frontier Programming Practices

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 AI.a2ml in this same folder — read by bots at session start.

1. The Thesis

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.

2. What’s In the Box

AffineScript ships with eight type-system features, grouped by role:

2.1. The Six Copied from the Family

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 UserId from ProductId so you can’t mix them up by accident.

  • Immutable by defaultlet x = 5 binds a value, not a variable. Use let mut when you want mutation. Matches const in 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.

2.2. The One Novel Contribution

  • 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.

2.3. The Soft Commitment

  • Refinement types (decidable fragment) — types that carry simple invariants: "this Int is always non-negative," "this array index is in bounds." Restricted to what the compiler can prove quickly without calling out to an SMT solver.

3. What’s Deliberately Not in the Box

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.

4. The Type System in Practice

4.1. Affine Types for Resources

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.

4.2. Row Polymorphism for Extensible Records

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.

4.3. Refinement Types for Invariants

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 PositiveInt

Refinements 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.

4.4. Phantom Types for Compile-Time Distinctions

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 ProductId

Zero 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."

4.5. Immutable by Default

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;   // ok

Matches const in JavaScript and let in ReScript — familiar to most developers.

4.6. Effect Tracking

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}   // both

Callers 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.

5. Idiomatic Patterns

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.

5.1. Type-State Machines (phantom + affine)

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.

5.2. Capability Types (phantom)

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>): Result

Callers must hold the right capability. Downgrades are visible in the type signature.

5.3. Branded Identifiers (phantom + nominal aliases)

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.

5.4. Trait-Like Dispatch (records + rows)

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);  // true

Dictionary passing gives you overloading and type-class-like dispatch without the compiler needing to support them natively.

5.5. Purity Tagging (phantom + effect rows)

Use effects as compile-time tags for purity:

fn pureCompute(x: Int): Int { x * 2 }            // no effect row, pure
fn ioOp(): String / {IO} { ... }                 // tagged IO

A 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.

5.6. Resource Pools (affine + higher-order functions)

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 await points raises an unresolved design question: if the async is cancelled mid-flight while holding the affine resource, what happens?

AI.a2ml flags algebraic effect handlers as out-of-scope partly because "multi-shot resume of continuations that captured affine resources is a soundness hole". The same family of issue applies here. A withFile over an async action doesn’t have a settled story for cancellation cleanup.

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.

5.7. Refinements in Argument Positions

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): Window

Compare 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.

5.8. Migrating from -script Languages

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.

5.8.1. Anti-pattern 1: Side-effect imports (let _ = X.constructor)

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);
  // ...
}

5.8.2. Anti-pattern 2: %raw JS / unsafe! blobs

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:

  1. Is there a typed binding (@affinescript/dom, @affinescript/pixijs, etc.) for the operation? Use it.

  2. Is the binding missing? Add it to the relevant binding package; do not inline a %raw equivalent in your application code.

  3. Is the operation fundamentally untyped (e.g. interacting with a third-party JS library no one has bound)? Use a documented Unsafe.embed escape 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

%raw("console.log(arg)")

Console.log (typed)

@affinescript/dom exposes a typed Console module — Console.log(message), Console.error(err), etc. — backed by an extern fn to the host’s console global.

Manual error-pane DOM injection via %raw("document.getElementById('err').innerHTML = …​")

Typed DOM binding

@affinescript/dom exposes Document.queryById(id) → Option<Element> and Element.setTextContent(el, str). The decision tree’s step 2 applied: the binding existed for queryById but setTextContent was missing, so it was added to the binding package rather than inlined as %raw.

%raw("try { …​ } catch (e) { e.message + ' at ' + e.stack }")

Structured Result + error sum

Result[E, T] with E as a sum naming each failure mode. The try/catch text-splicing became a match on the structured error at the surface. See Anti-pattern 5 (Promise umbrella) for the wider pattern.

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.

5.8.3. Anti-pattern 3: Monolithic init functions

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.

5.8.4. Anti-pattern 4: Inline lambda callbacks

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.

5.8.5. Anti-pattern 5: Untyped exceptions / Promise umbrella

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.

5.8.6. Anti-pattern 6: Mutable globals / setters for callbacks

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.

6. The Compilation Target

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.

6.1. The WASM Co-processor Model

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.

7. Faces: Frontend Surfaces

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.

7.1. Faces Are Brand Surfaces, Not Compiler Forks

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.

7.2. Identity Belongs in Content, Not in the Filesystem

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.

7.3. Pure Text-to-Text Transformers Need Snapshot Tests Plus Round-Trip Parse

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:

  1. Run preview-<face> over an example file, capture stdout.

  2. Diff against tests/faces/<base>.expected.txt. If the snapshot moves, you see exactly what changed.

  3. 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.

7.4. Examples Are Tests, Not Demonstrations

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.

7.5. Error-Vocabulary Cost Scales as O(faces × error_kinds)

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.

7.6. Span Fidelity Has a UX Cost

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.

8. Design Principles

8.1. Ergonomics Over Expressiveness

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.

8.2. Copy What Works

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.

8.3. Small, Chosen Feature Set

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.

8.4. Scope Discipline

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.

8.5. Honest Status

Completion claims must reflect what the code actually enforces, not what it aspires to. "The type checker compiles" is not the same as "the type checker enforces the advertised rules on user programs." STATE.a2ml and related docs split those two things explicitly.

9. Getting Help

  • 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.

10. The Typed WASM Project

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.