|
Note
|
This guide is the systematic companion to
The machine-readable companion for AI agents is |
Re-decompose. Do not transliterate.
A 1:1 syntactic port — let mutable becomes mut, option<T> becomes Option[T], try/catch becomes try/catch, classes become records-with-methods — produces code that type-checks in AffineScript while still carrying the design problems AffineScript was built to solve.
The Frontier Guide’s thesis: every X-Script’s six pathologies share one root — the runtime does not know the program’s intent. Translation must therefore re-express intent, not just syntax. If a faithful port leaves the original design untouched, the destination language is being used as a syntax veneer and none of its guarantees do any work.
The rest of this document is a catalogue of common source-language patterns and the re-decompositions they invite.
-
Start from the source file. Identify the patterns it contains using Source-Pattern Index.
-
For each pattern, read the Decomposition column — that is the AffineScript shape, not just the AffineScript syntax.
-
If the source mixes several patterns into one structure (a class with mutable fields, an exception path, and a callback), expect the AffineScript output to have more, smaller declarations — not the same number of larger ones.
-
Where two decompositions are valid, the Decision Criteria table tells you which one fits.
-
The Anti-patterns: Faithful-but-monolithic Translation section shows what a faithful-but-monolithic translation looks like, paired with its re-decomposed form.
| Source | Decomposition | Why |
|---|---|---|
|
|
Local mutation in a single owner stays local; mutation visible across calls becomes an effect. |
|
Same choice as |
A bare |
|
|
Direct mapping; no decomposition needed. |
|
|
Direct mapping; tighten |
|
|
Replaces an untyped throw path with an exhaustive return type. The compiler forces every caller to handle the |
|
|
Promises silently allow racing, ignoring errors, and detached lifetimes. The |
ReScript object types |
Records with row variables |
Row polymorphism replaces structural object types principle-for-principle. |
Polymorphic variants |
Open variant rows / sum types |
Same idea, integrated with the rest of the type system rather than parallel to it. |
Module functors |
Row-polymorphic functions parameterised over their effects |
Most functor uses are an indirect way to parameterise over a small set of operations — effects do this directly. |
Class via PPX ( |
Owned record + standalone functions; lift effectful methods into effect rows |
A class is a tangle of state, methods, and implicit lifetimes. Each strand becomes its own declaration. |
|
|
In ReScript, |
Module-level getter for a singleton runtime — |
Lift to an effect ( |
A service-locator is mutable global state with a thin |
| Source | Decomposition | Why |
|---|---|---|
|
|
Frontier Guide chapter 1: one nothing, not three states. |
|
|
A bare |
|
|
Same reasoning as the ReScript case. |
|
Owned record + standalone functions; lift mutation to |
Encapsulation comes from ownership and module scope, not from class privacy. |
|
Forbidden in AffineScript output. Pin the type, or describe the unknown shape with a row variable. |
|
Discriminated unions ( |
Sum types with named constructors; |
Pattern matching is exhaustive; the compiler enforces what TypeScript can only suggest. |
|
Direct mapping. Both operands must be |
Until 2026-05-02 the AffineScript typechecker hard-coded arithmetic as |
|
AffineScript: |
Until 2026-05-02 the borrow checker rejected indexed writes through |
Backends often need to call runtime ops that AffineScript’s resolver doesn’t know about — GPU shuffle intrinsics, ONNX operators, DSP primitives, hardware-simulation tasks. The canonical pattern is:
-
Declare the op as a stub function in user code with the right signature.
-
The resolver and typechecker accept the stub.
-
The backend 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 — the body is placeholder; the ONNX
// codegen pattern-matches the name `relu` → ONNX op `Relu`, etc.
fn relu(x: Array[Float]) -> Array[Float] { x }
fn add(a: Array[Float], b: Array[Float]) -> Array[Float] { a }
// Use them as if they were real:
fn graph(x: Array[Float], y: Array[Float]) -> Array[Float] {
let s: Array[Float] = add(x, y);
let r: Array[Float] = relu(s);
r
}The affinescript compile graph.affine -o graph.onnx invocation produces a real ONNX ModelProto with an Add node and a Relu node — the codegen ignored the stub bodies and used the names. The same pattern applies to every kernel-sublanguage backend (Tier C in the backend taxonomy).
Why this matters for migration: when porting code that calls platform-specific functions, you do not need to extend AffineScript itself. Declare the platform’s API as stubs in your translation unit, and let the backend recognise them. This keeps the frontend small and the backend self-contained.
ReScript networking is fetch(…) returning a Js.Promise.t, usually
wrapped in a .then/.catch umbrella. Do not port the promise. Port to
the Http stdlib module (use Http::{fetch, get, post, request};),
whose surface is a typed request/response with an honest effect row:
pub fn fetch(req: Request) -> Response / { Net, Async }
// Request #{ url, method, headers: [(String, String)], body: Option<String> }
// Response #{ status: Int, headers: [(String, String)], body: String }Re-decomposition rules:
-
The
.catchumbrella becomes amatchonis_ok(resp)/resp.status— failure is data, not a detached rejected promise. -
Headers are an association list
[(String, String)], not an opaque object. Build/read them explicitly. (This will becomeDictonce #162 lands; the change is source-compatible for callers using the helpers.) -
The response
bodyis raw text. JSON decoding is a separate step (the #161Jsonprimitive) — do not conflate transport with parsing. -
Every function that transitively calls
Httpcarries/ { Net, Async }in its signature. That is the point: the network and its asynchrony are visible in the type, where ReScript hid them in aPromise.
Backend status: the Deno-ESM target lowers this to a globalThis.fetch
round-trip (Deno / Node 18+ / browser ESM) and is fully exercised
end-to-end. The typed-wasm target is a tracked next slice — the
convergence ABI shared with Ephapax (see Settled Decisions,
typed-wasm ADR-004); AffineScript is not coupled to it.
When a source pattern admits more than one AffineScript shape, choose with these tests.
-
Option[T]when absence is normal — a lookup that may not find anything, a config field that may be unset. -
Result[T, E]when absence has a reason you can describe — parse failure, validation error, IO error.
If you find yourself reaching for Result[T, ()] or Result[T, String], you probably wanted Option[T] (or a richer E).
-
mutfor local mutation contained in a single owner — buffer construction, an accumulator inside a function. -
State[S]effect for mutation that must be observable across calls — game state, session state, a setting visible to many subsystems.
If two callers need to see the same mutation, it is no longer local — lift it.
-
ref Buffer— the caller keeps the value and the function only reads it. -
mut Buffer— the function modifies in place; the caller keeps using the value afterwards. -
own Buffer— the function consumes the value; the caller cannot use it after the call.
The default for resources (files, sockets, tokens, allocations) is own — anything else hides the lifetime.
-
own(affine) — may be used at most once. Dropping is allowed. -
@linear— must be used exactly once. Dropping is a compile error.
Prefer own. Reach for @linear only when forgetting to consume the value is a real bug — typically protocol handles, transactions, and capability tokens.
When a single user action must update both an in-memory subsystem AND persist to storage (volume that touches Audio and Storage, theme that touches UI and Storage, etc.), there are two ways to express it:
| Shape | When to choose |
|---|---|
One function calling two (or more) effects. Default. |
Each effect is independently meaningful and independently testable. The wrapper function is the coupling; it is visible at every call site through both effects appearing in the row. |
A wrapper effect that internally sequences the two. E.g. |
The runtime invariant is "Audio and Storage must always agree" and partial success is a bug. The wrapper’s handler enforces atomicity (both succeed or neither does), which the two-effect call site cannot. |
The decision tracks the same logic as mut vs State: keep coupling local unless the invariant must be observable across callers. If two callers can validly compose Audio.set_master_volume and Storage.set_number differently — for example, "set the audio without persisting" for a transient ducking effect — the wrapper effect is wrong, because it forecloses that composition.
The following ReScript is a small file buffer that conflates state, lifetime, and IO.
// ReScript — original
type fileBuffer = {
mutable data: array<string>,
mutable open_: bool,
}
let make = (): fileBuffer => { data: [], open_: true }
let read = (b: fileBuffer): string =>
if b.open_ { Array.joinWith(b.data, "") } else { raise(Failure("closed")) }
let write = (b: fileBuffer, s: string): unit => {
if !b.open_ { raise(Failure("closed")) }
b.data = Array.concat(b.data, [s])
Console.log("wrote " ++ s)
}
let close = (b: fileBuffer): unit => { b.open_ = false }// AffineScript — same shape, none of the guarantees doing any work
type FileBuffer = own {
data: mut Array[String],
open_: mut Bool,
}
fn make() -> own FileBuffer { FileBuffer #{ data: [], open_: true } }
fn read(b: ref FileBuffer) -> String {
if b.open_ { String.join(b.data, "") } else { panic("closed") }
}
fn write(b: mut FileBuffer, s: String) -> () {
if !b.open_ { panic("closed") }
b.data = b.data ++ [s];
// IO is silently invisible — same problem as the original
}
fn close(b: mut FileBuffer) -> () { b.open_ = false }This compiles. It is also no better than the ReScript original: the closed invariant is still a runtime check, the IO is still invisible, and close does not actually consume the buffer.
// Two types — a closed buffer is statically distinguishable from an open one
type OpenBuffer = own { data: Array[String] }
type ClosedBuffer = own { data: Array[String] }
effect IO { fn log(s: String); }
fn make() -> own OpenBuffer { OpenBuffer #{ data: [] } }
// read takes a borrow — caller keeps the buffer:
fn read(b: ref OpenBuffer) -> String { String.join(b.data, "") }
// write is in-place; IO is in the row:
fn write(b: mut OpenBuffer, s: String) -> () / IO {
b.data = b.data ++ [s];
IO.log("wrote " ++ s)
}
// close consumes Open and returns Closed — "use after close" becomes a type error:
fn close(b: own OpenBuffer) -> own ClosedBuffer { ClosedBuffer #{ data: b.data } }What changed:
-
closedmoved from a runtime field to a type (OpenBuffervsClosedBuffer) — "use after close" is now a compile error. -
closeconsumes its argument (own) — there is nobleft to misuse afterwards. -
IOis in the effect row ofwrite— every caller ofwritedeclares it transitively. -
The mutable boolean is gone;
datais reached only throughmut OpenBuffer, which makes ownership explicit.
The two versions are roughly the same number of lines. They are different programs.
Case studies from in-flight migrations land in docs/guides/lessons/migrations/. (The sibling lessons/ folder is the 10-lesson tutorial track for new AffineScript users — different audience.)
Existing entries:
-
idaptik
Hitbox.res→Hitbox.affine— the first compilable translation; pure-leaf collision primitives. Surfaces the v0.1.0 Float-arithmetic typechecker gap. -
idaptik
UserSettings.res(design-only) — multi-pattern, dependency-heavy persistence layer. Stress-tests the playbook against five external module dependencies; surfaces three small playbook gaps (addressed in v1.1). -
idaptik
PlayerHP.res→PlayerHP.affine— six-field mutable record with three orthogonal aspects (HP, i-frames, knockback). Compiled to a 952-byte WASM module via the v0.1.0 toolchain. Stress-tests themutvsStatedecision criterion; surfaces the aspect-decomposition pattern as a v1.2 candidate addition, and the v0.1.0mut-with-struct-field-assignment gap as a second expedient (alongside Float→Int).
If you complete a non-trivial .res → .affine translation and the re-decomposition reveals something future translators would benefit from, write it up there. The format is loose: original snippet, faithful port, re-decomposed port, and what the second one buys you. Mark the lesson "design-only" if the destination toolchain cannot yet compile it — the design walk-through still pressures the playbook.
-
Frontier Guide: The Unveiling — the what and why of AffineScript’s design.
-
Frontier Programming Practices — the wider design philosophy.
-
AI.a2ml— machine-readable companion, read by AI agents at session start. -
Settled Decisions — architectural choices and why they were made.
-
Language Specification — authoritative grammar and semantics.
| Revision | Date | Notes |
|---|---|---|
1.0 |
2026-05-02 |
Initial draft. ReScript and TypeScript pattern indices, decision criteria, file-buffer anti-pattern. Companion to |
1.1 |
2026-05-02 |
Two additions surfaced by the idaptik UserSettings design walk-through: (1) service-locator-globals row added to the ReScript pattern index; (2) coupled-writes subsection added to Decision Criteria, with the rule "keep coupling local unless the invariant must be observable across callers." No removals; existing v1.0 guidance unchanged. |
1.2 |
2026-05-02 |
Backend-buildout findings folded into TS→AS index and a new recipe: (1) Float-arithmetic typechecker gap is now closed (no more Int-scaling workaround); (2) |
1.3 |
2026-05-18 |
New |