|
| 1 | +// SPDX-License-Identifier: PMPL-1.0-or-later |
| 2 | +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> |
| 3 | += AffineScript Migration Playbook: Re-decomposition Patterns |
| 4 | +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> |
| 5 | +v1.0, 2026-05-02 |
| 6 | +:sectnums: |
| 7 | +:toc: left |
| 8 | +:icons: font |
| 9 | +:source-highlighter: rouge |
| 10 | +:revnumber: 1.0 |
| 11 | +:revdate: 2026-05-02 |
| 12 | + |
| 13 | +[NOTE] |
| 14 | +==== |
| 15 | +This guide is the **systematic companion** to link:frontier-guide.adoc[`frontier-guide.adoc`]. |
| 16 | +
|
| 17 | +* The Frontier Guide unveils what AffineScript *is* — read it if you are starting a new program. |
| 18 | +* This playbook describes how to **carry an existing codebase across the boundary** — read it if you are translating ReScript, TypeScript, or another `-script` family language into AffineScript. |
| 19 | +
|
| 20 | +The machine-readable companion for AI agents is link:frontier-programming-practices/AI.a2ml[`AI.a2ml`]. |
| 21 | +==== |
| 22 | + |
| 23 | +== The Cardinal Rule |
| 24 | + |
| 25 | +**Re-decompose. Do not transliterate.** |
| 26 | + |
| 27 | +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_. |
| 28 | + |
| 29 | +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. |
| 30 | + |
| 31 | +The rest of this document is a catalogue of common source-language patterns and the re-decompositions they invite. |
| 32 | + |
| 33 | +== How to Use This Playbook |
| 34 | + |
| 35 | +. Start from the source file. Identify the patterns it contains using <<source-pattern-index>>. |
| 36 | +. For each pattern, read the **Decomposition** column — that is the AffineScript *shape*, not just the AffineScript *syntax*. |
| 37 | +. 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. |
| 38 | +. Where two decompositions are valid, the <<decision-criteria>> table tells you which one fits. |
| 39 | +. The <<anti-patterns>> section shows what a faithful-but-monolithic translation looks like, paired with its re-decomposed form. |
| 40 | + |
| 41 | +[#source-pattern-index] |
| 42 | +== Source-Pattern Index |
| 43 | + |
| 44 | +=== ReScript → AffineScript |
| 45 | + |
| 46 | +[cols="1,2,2"] |
| 47 | +|=== |
| 48 | +| Source | Decomposition | Why |
| 49 | + |
| 50 | +| `let mutable x = ...` |
| 51 | +| `mut` field on an owned record, **or** lift to a `State` effect |
| 52 | +| Local mutation in a single owner stays local; mutation visible across calls becomes an effect. |
| 53 | + |
| 54 | +| `ref<'a>` (mutable cell) |
| 55 | +| Same choice as `let mutable`; default to lifting to an effect when the cell crosses a function boundary |
| 56 | +| A bare `ref` is mutation that has already escaped its scope — typically the wrong default. |
| 57 | + |
| 58 | +| `option<'a>` (built-in) |
| 59 | +| `Option[T]` |
| 60 | +| Direct mapping; no decomposition needed. |
| 61 | + |
| 62 | +| `result<'a, 'e>` |
| 63 | +| `Result[T, E]` |
| 64 | +| Direct mapping; tighten `'e` from `string` or `exn` to a named error type if you can. |
| 65 | + |
| 66 | +| `exception Foo` + `try/catch` |
| 67 | +| `Result[T, E]` where `E` is a named variant covering each failure |
| 68 | +| Replaces an untyped throw path with an exhaustive return type. The compiler forces every caller to handle the `Err` shape. |
| 69 | + |
| 70 | +| `Js.Promise.t<'a>` / async ReScript |
| 71 | +| `Async` effect on the function row |
| 72 | +| Promises silently allow racing, ignoring errors, and detached lifetimes. The `Async` effect makes lifetime and error explicit. |
| 73 | + |
| 74 | +| ReScript object types `Js.t<{..}>` |
| 75 | +| Records with row variables `{ field: T, ..r }` |
| 76 | +| Row polymorphism replaces structural object types principle-for-principle. |
| 77 | + |
| 78 | +| Polymorphic variants `[#A \| #B]` |
| 79 | +| Open variant rows / sum types |
| 80 | +| Same idea, integrated with the rest of the type system rather than parallel to it. |
| 81 | + |
| 82 | +| Module functors |
| 83 | +| Row-polymorphic functions parameterised over their effects |
| 84 | +| Most functor uses are an indirect way to parameterise over a small set of operations — effects do this directly. |
| 85 | + |
| 86 | +| Class via PPX (`%@react.component`, etc.) |
| 87 | +| Owned record + standalone functions; lift effectful methods into effect rows |
| 88 | +| A class is a tangle of state, methods, and implicit lifetimes. Each strand becomes its own declaration. |
| 89 | + |
| 90 | +| `unit` returns from impure code |
| 91 | +| `() / IO` (or whichever effects apply) |
| 92 | +| In ReScript, `unit` says nothing about whether the function did IO. In AffineScript, the effect row makes it visible. |
| 93 | +|=== |
| 94 | + |
| 95 | +=== TypeScript → AffineScript |
| 96 | + |
| 97 | +[cols="1,2,2"] |
| 98 | +|=== |
| 99 | +| Source | Decomposition | Why |
| 100 | + |
| 101 | +| `T \| null \| undefined` |
| 102 | +| `Option[T]` |
| 103 | +| Frontier Guide chapter 1: one nothing, not three states. |
| 104 | + |
| 105 | +| `throw new Error()` + `try/catch` |
| 106 | +| `Result[T, E]` for recoverable errors; `Exn[E]` effect for cross-cutting failure |
| 107 | +| A bare `throw` in TypeScript is invisible at the call site. Both replacements make it visible. |
| 108 | + |
| 109 | +| `Promise<T>` / `async function` |
| 110 | +| `Async` effect |
| 111 | +| Same reasoning as the ReScript case. |
| 112 | + |
| 113 | +| `class Foo { private x; method() {} }` |
| 114 | +| Owned record + standalone functions; lift mutation to `mut` or `State` |
| 115 | +| Encapsulation comes from ownership and module scope, not from class privacy. |
| 116 | + |
| 117 | +| `any` / `unknown` |
| 118 | +| Forbidden in AffineScript output. Pin the type, or describe the unknown shape with a row variable. |
| 119 | +| `any` is the absence of a thesis. Every translation must replace it with one. |
| 120 | + |
| 121 | +| Discriminated unions (`{ kind: "a" } \| { kind: "b" }`) |
| 122 | +| Sum types with named constructors; `match` on the constructor |
| 123 | +| Pattern matching is exhaustive; the compiler enforces what TypeScript can only suggest. |
| 124 | +|=== |
| 125 | + |
| 126 | +[#decision-criteria] |
| 127 | +== Decision Criteria |
| 128 | + |
| 129 | +When a source pattern admits more than one AffineScript shape, choose with these tests. |
| 130 | + |
| 131 | +=== `Option[T]` vs `Result[T, E]` |
| 132 | + |
| 133 | +* **`Option[T]`** when *absence is normal* — a lookup that may not find anything, a config field that may be unset. |
| 134 | +* **`Result[T, E]`** when *absence has a reason you can describe* — parse failure, validation error, IO error. |
| 135 | + |
| 136 | +If you find yourself reaching for `Result[T, ()]` or `Result[T, String]`, you probably wanted `Option[T]` (or a richer `E`). |
| 137 | + |
| 138 | +=== `mut` vs effect |
| 139 | + |
| 140 | +* **`mut`** for local mutation contained in a single owner — buffer construction, an accumulator inside a function. |
| 141 | +* **`State[S]` effect** for mutation that must be observable across calls — game state, session state, a setting visible to many subsystems. |
| 142 | + |
| 143 | +If two callers need to see the same mutation, it is no longer *local* — lift it. |
| 144 | + |
| 145 | +=== `ref` vs `mut` vs `own` |
| 146 | + |
| 147 | +* **`ref Buffer`** — the caller keeps the value and the function only reads it. |
| 148 | +* **`mut Buffer`** — the function modifies in place; the caller keeps using the value afterwards. |
| 149 | +* **`own Buffer`** — the function consumes the value; the caller cannot use it after the call. |
| 150 | + |
| 151 | +The default for resources (files, sockets, tokens, allocations) is `own` — anything else hides the lifetime. |
| 152 | + |
| 153 | +=== Linear (`@linear`) vs affine (`own`) |
| 154 | + |
| 155 | +* **`own`** (affine) — *may* be used at most once. Dropping is allowed. |
| 156 | +* **`@linear`** — *must* be used exactly once. Dropping is a compile error. |
| 157 | + |
| 158 | +Prefer `own`. Reach for `@linear` only when forgetting to consume the value is a real bug — typically protocol handles, transactions, and capability tokens. |
| 159 | + |
| 160 | +[#anti-patterns] |
| 161 | +== Anti-patterns: Faithful-but-monolithic Translation |
| 162 | + |
| 163 | +The following ReScript is a small file buffer that conflates state, lifetime, and IO. |
| 164 | + |
| 165 | +[source,rescript] |
| 166 | +---- |
| 167 | +// ReScript — original |
| 168 | +type fileBuffer = { |
| 169 | + mutable data: array<string>, |
| 170 | + mutable open_: bool, |
| 171 | +} |
| 172 | +
|
| 173 | +let make = (): fileBuffer => { data: [], open_: true } |
| 174 | +
|
| 175 | +let read = (b: fileBuffer): string => |
| 176 | + if b.open_ { Array.joinWith(b.data, "") } else { raise(Failure("closed")) } |
| 177 | +
|
| 178 | +let write = (b: fileBuffer, s: string): unit => { |
| 179 | + if !b.open_ { raise(Failure("closed")) } |
| 180 | + b.data = Array.concat(b.data, [s]) |
| 181 | + Console.log("wrote " ++ s) |
| 182 | +} |
| 183 | +
|
| 184 | +let close = (b: fileBuffer): unit => { b.open_ = false } |
| 185 | +---- |
| 186 | + |
| 187 | +=== Faithful-but-monolithic translation — don't do this |
| 188 | + |
| 189 | +[source,affinescript] |
| 190 | +---- |
| 191 | +// AffineScript — same shape, none of the guarantees doing any work |
| 192 | +type FileBuffer = own { |
| 193 | + data: mut Array[String], |
| 194 | + open_: mut Bool, |
| 195 | +} |
| 196 | +
|
| 197 | +fn make() -> own FileBuffer { FileBuffer { data: [], open_: true } } |
| 198 | +
|
| 199 | +fn read(b: ref FileBuffer) -> String { |
| 200 | + if b.open_ { String.join(b.data, "") } else { panic("closed") } |
| 201 | +} |
| 202 | +
|
| 203 | +fn write(b: mut FileBuffer, s: String) -> () { |
| 204 | + if !b.open_ { panic("closed") } |
| 205 | + b.data = b.data ++ [s]; |
| 206 | + // IO is silently invisible — same problem as the original |
| 207 | +} |
| 208 | +
|
| 209 | +fn close(b: mut FileBuffer) -> () { b.open_ = false } |
| 210 | +---- |
| 211 | + |
| 212 | +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. |
| 213 | + |
| 214 | +=== Re-decomposed translation |
| 215 | + |
| 216 | +[source,affinescript] |
| 217 | +---- |
| 218 | +// Two types — a closed buffer is statically distinguishable from an open one |
| 219 | +type OpenBuffer = own { data: Array[String] } |
| 220 | +type ClosedBuffer = own { data: Array[String] } |
| 221 | +
|
| 222 | +effect IO { fn log(s: String); } |
| 223 | +
|
| 224 | +fn make() -> own OpenBuffer { OpenBuffer { data: [] } } |
| 225 | +
|
| 226 | +// read takes a borrow — caller keeps the buffer: |
| 227 | +fn read(b: ref OpenBuffer) -> String { String.join(b.data, "") } |
| 228 | +
|
| 229 | +// write is in-place; IO is in the row: |
| 230 | +fn write(b: mut OpenBuffer, s: String) -> () / IO { |
| 231 | + b.data = b.data ++ [s]; |
| 232 | + IO.log("wrote " ++ s) |
| 233 | +} |
| 234 | +
|
| 235 | +// close consumes Open and returns Closed — "use after close" becomes a type error: |
| 236 | +fn close(b: own OpenBuffer) -> own ClosedBuffer { ClosedBuffer { data: b.data } } |
| 237 | +---- |
| 238 | + |
| 239 | +What changed: |
| 240 | + |
| 241 | +. `closed` moved from a runtime field to a *type* (`OpenBuffer` vs `ClosedBuffer`) — "use after close" is now a compile error. |
| 242 | +. `close` consumes its argument (`own`) — there is no `b` left to misuse afterwards. |
| 243 | +. `IO` is in the effect row of `write` — every caller of `write` declares it transitively. |
| 244 | +. The mutable boolean is gone; `data` is reached only through `mut OpenBuffer`, which makes ownership explicit. |
| 245 | + |
| 246 | +The two versions are roughly the same number of lines. They are different programs. |
| 247 | + |
| 248 | +== Lessons from Real Migrations |
| 249 | + |
| 250 | +Case studies from in-flight migrations land in link:lessons/[`docs/guides/lessons/`]. |
| 251 | + |
| 252 | +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. |
| 253 | + |
| 254 | +== See Also |
| 255 | + |
| 256 | +* link:frontier-guide.adoc[Frontier Guide: The Unveiling] — the *what* and *why* of AffineScript's design. |
| 257 | +* link:frontier-programming-practices/Human_Programming_Guide.adoc[Frontier Programming Practices] — the wider design philosophy. |
| 258 | +* link:frontier-programming-practices/AI.a2ml[`AI.a2ml`] — machine-readable companion, read by AI agents at session start. |
| 259 | +* link:../specs/SETTLED-DECISIONS.adoc[Settled Decisions] — architectural choices and why they were made. |
| 260 | +* link:../specs/SPEC.md[Language Specification] — authoritative grammar and semantics. |
| 261 | + |
| 262 | +[appendix] |
| 263 | +== Revision History |
| 264 | + |
| 265 | +[cols="1,1,3"] |
| 266 | +|=== |
| 267 | +| Revision | Date | Notes |
| 268 | + |
| 269 | +| 1.0 |
| 270 | +| 2026-05-02 |
| 271 | +| Initial draft. ReScript and TypeScript pattern indices, decision criteria, file-buffer anti-pattern. Companion to `frontier-guide.adoc` v1.0. |
| 272 | +|=== |
0 commit comments