Blog › ICP guides

Grain developer on retainer: Box mutability, persistent data structures, pattern matching, WebAssembly compilation, and Grain functional programming on monthly retainer

September 26, 2026 · ~15 min read

A Grain program was accumulating transformed items across a processing pipeline. The developer called List.map(transform, items) to apply a transformation and then used the items binding later in the same function expecting the mapped values to be present. In Grain, all data structures including List are persistent and immutable by default — List.map returns a new list with the transformed values without modifying the original. The developer had written let result = List.map(transform, items) and then continued using items instead of result, reading stale pre-transformation data at every subsequent access. Stale accesses per function: 3. The developer restructured the pipeline using Grain’s pipe operator to chain the transformation into the data flow: let items = items |> List.map(transform) |> nextStep(), binding the result back to items so subsequent code reads the transformed list. Stale accesses per function: 3 → 0. The Grain developer on retainer diagnosed the persistent-data stale-binding pattern: the issue was not the List.map call but the separation between the new binding and the subsequent usage that continued reading the old binding — a class of bug that is invisible at the call site and only visible when the downstream consumer produces wrong results.

The work log entry read “fixed data processing pipeline, 7h.” It names the result and duration. It cannot explain why the stale-binding pattern persisted despite the List.map call — Grain’s persistent data structures are immutable at the data level: the original items list is unchanged after List.map; the returned new list exists only in the result binding; the shadowed items in scope continues to refer to the original list; no runtime error is raised because reading the original list is valid; the bug manifests as wrong output values, not a type error or crash. It cannot explain when Grain’s Box type is the correct tool for explicit mutability — a Box is a heap-allocated mutable cell; Box.make(value) allocates a box containing value; Box.unbox(b) reads the current value; b := newValue updates the box in-place; a Box is the right model when a value must accumulate changes across multiple function calls without passing it explicitly through every call in the chain (a counter, an accumulator, a mutable configuration); the wrong model when the intent is persistent transformation (where pipe chaining is cleaner and the original data should be preserved). It cannot explain Grain’s pattern matching exhaustiveness requirement — every match` expression must cover all variants of the enum being matched; the Grain compiler reports each unhandled variant as a type error; when` guard conditions in match arms further constrain which values a pattern matches; nested destructuring of tuples and records in match arms composes with guard conditions; the exhaustiveness requirement is the mechanism that makes pattern matching safe across enum evolution. The 7 hours of persistent-data pipeline analysis, Box mutability design, and module interface restructuring are invisible in the diff.

Grain data model: immutable defaults, Box for mutation, persistent List/Map/Set, and Array for indexed mutation

Grain’s data model is immutable by default at every level: primitive values (Number, Bool, String, Char), standard library collections (List, Map, Set), and user-defined record types are all immutable after construction. This immutability is not enforced by a type-level uniqueness system like Rust’s borrow checker; it is the semantic model of all values unless explicitly opted into mutability via Box or Array. A let binding in Grain creates a new immutable name in the current scope; reassigning a name with let shadows the previous binding in the rest of the scope but does not modify any heap value. Mutation in Grain requires one of two explicit choices: Box for arbitrary heap-allocated mutable values, or Array for fixed-length indexed mutable sequences. Array provides O(1) indexed read and write (array[i] for read, array[i] = value for write) and is the correct type when a program needs a mutable indexed container with a known or fixed size. Box is the correct type when a single value needs to accumulate mutations across function call boundaries without explicit threading through every call site.

The pipe operator |> is the idiomatic Grain mechanism for chaining persistent data structure transformations. items |> List.map(transform) |> List.filter(predicate) |> List.reduce(accumulate, initial) passes the result of each step as the final argument to the next step, making the transformation pipeline explicit and eliminating the stale-binding pattern by ensuring each step’s output is immediately consumed by the next step. The pipe model works naturally with Grain’s standard library, which is designed with data-last argument order: the collection being operated on is the last parameter, matching the pipe operator’s semantics. Map.set(key, value, map) returns a new map with the key-value pair added without modifying the original; the idiomatic update is let map = Map.set(key, value, map) to shadow the binding, or map |> Map.set(key, value) in a pipeline. Grain was designed by Oscar Spencer and first released in 2017. Its WebAssembly-first compilation target, ML-inspired type system, and gradual adoption of functional patterns make it suited for full-stack WebAssembly applications, frontend logic compiled to WASM, and serverless edge computing. Its retainer work is primarily in teams building browser-side WASM modules, CLI tools compiled to WASM for cross-platform distribution, and applications targeting WASI for server-side WASM execution. Its closest retainer neighbors are Elm developer retainers (shared ML-family syntax and functional purity) and OCaml developer retainers (shared algebraic type system), but Grain’s explicit Box mutability model, WebAssembly-first compilation, and provide-based module export system make the retainer work distinct.

Grain module system: provide, use, from…import, and WebAssembly compilation pipeline

Grain’s module system uses file-based modules where each .gr file is a module. The provide keyword marks bindings as exported: provide let transform = x => x + 1 exports transform from the module; bindings without provide are private to the module. The use statement imports all provided exports from a module into the current namespace: use Map.* imports all Map module exports. The from ... import syntax performs selective import: from "list" import { map, filter, reduce } imports only the named functions. Module aliasing allows renaming: from "map" import Map as HashMap. The standard library modules cover most functional programming needs: List for persistent linked lists, Array for mutable indexed sequences, Map for persistent hash maps, Set for persistent hash sets, String for string manipulation, Number for numeric operations, Buffer for mutable byte buffers, Bytes for immutable byte sequences.

Grain’s WebAssembly compilation pipeline consists of grain compile (compiles a .gr file to a .wasm module), grain run (compiles and runs via Node.js with the WASM runtime), and grain pack (bundles a program with all its dependencies into a self-contained WASM module). The WasmI32, WasmI64, WasmF32, and WasmF64 types in the WasmI32, WasmI64, WasmF32, WasmF64 modules provide direct access to WASM numeric types, enabling zero-overhead interop with host-provided WASM functions and manual memory management via the Memory module. The Memory module provides direct access to WASM linear memory: Memory.malloc allocates bytes; Memory.free releases them; Memory.load and Memory.store read and write at specific addresses. This low-level layer is used in performance-critical modules, host interop code, and platform-specific system calls. Retainer work at this level involves designing the boundary between idiomatic Grain high-level code and the low-level WASM interop layer, auditing memory allocation and deallocation symmetry, and optimizing hot paths by reducing GC pressure through careful use of mutable Array and Buffer instead of persistent list allocation.

How HourTab tracks Grain developer retainer hours

Grain retainer work carries the invisible-hours problem specific to immutable-by-default languages: the program compiles and runs without error, but produces wrong results because a transformation produced a new value that was never stored in the binding that subsequent code reads. The persistent-data stale-binding pattern described above is the most common correctness issue in Grain programs written by developers coming from mutable-by-default languages: they model List.map as an in-place operation, write the call without binding the result to the name they use next, and the original stale data flows through without any compile-time or runtime signal. Diagnosing this requires understanding Grain’s persistent data semantics, the distinction between let shadowing and Box mutation, and the pipe operator as the compositional tool for making transformation chains explicit. A retainer engagement typically involves data flow audit (every transformation result verified to be consumed by the next step), mutability audit (Box usage verified for correct make/unbox/update semantics), and module interface audit (every provide export verified against the module’s intended public API).

HourTab gives Grain developers a public retainer-hours URL they send to clients — typically teams building browser-side WASM modules for computation-heavy frontend logic, CLI tools compiled to WASM for cross-platform distribution without runtime dependencies, and serverless edge functions targeting WASI. For Grain retainers, each work log entry should name the mechanism (persistent data: List.map return value chain, pipe operator composition, stale-binding diagnosis; Box: Box.make/Box.unbox/Box.:= in-place update, accumulator design; module: provide export, selective import, module aliasing; WASM: WasmI32/WasmI64 type interop, Memory module allocation, wasm-opt optimization), the specific binding name, data structure operation, and before/after stale-access or type-error count, and the design rationale. Grain retainers are often compared to Elm developer retainers for the shared immutable-data-default context, but Grain’s explicit Box mutability mechanism, provide-based module export system, WasmI32/WasmI64 direct WASM type access, and Memory module linear memory management make the retainer work distinct in mutability model design, module interface engineering, and WebAssembly performance optimization. HourTab’s work log makes the data flow analysis, Box mutability design, and WASM interop audit visible to clients who would otherwise see only the symptom — wrong output from a pipeline that appeared correct — and not understand why the fix required understanding that Grain’s List.map returns a new list and does not modify the original, and why the stale binding was silently valid because reading the original list is not an error, and why the pipe operator |> is the idiomatic mechanism for making the transformation chain explicit so the result flows directly into the next operation without an intermediate binding that could become stale.

Track Grain developer retainer hours without the status emails

HourTab gives Grain developers a public URL per client retainer. One link, no login, live burn-down. Your clients stop asking “how many hours do I have left?” and your data flow audit log — stale-binding diagnosis, Box mutability design, WASM interop analysis — becomes the proof of value that gets the retainer renewed.

See HourTab pricing →

FAQ: Grain developer retainers

What does a Grain developer on retainer typically do?

A Grain developer on monthly retainer covers Grain data types (immutable by default; Box for explicit heap mutability via Box.make, Box.unbox, Box.:=; persistent List, Array, Map, Set from standard library; record types with field access; enum variants as algebraic data types; tuple destructuring), Grain module system (module declarations; use and from...import syntax; provide keyword for selective export; standard library modules — List, Array, Map, Set, String, Int32, Int64, Float64, Buffer, Bytes; module aliasing), and Grain WebAssembly compilation (grain compile produces .wasm output; grain run executes via Node.js WASM runtime; grain pack bundles with dependencies; wasm-opt integration for size/speed optimization; WasmI32, WasmI64, WasmF32, WasmF64 for direct WASM type access; Memory module for manual linear memory management).

What Grain work is most commonly underlogged in a retainer?

Persistent-list stale-binding diagnosis (developer called List.map(transform, items) without binding result; items unchanged; stale accesses: 3/function; restructured with pipe items |> List.map(transform); stale accesses: 3/function → 0; 6–10 hrs invisible); Box explicit mutability design (Box.make(value) allocates mutable cell; Box.unbox(b) reads current value; b := newValue updates in-place; required when state must accumulate across function calls; misuse of let rebinding where Box was intended; 5–9 hrs invisible); pattern match exhaustiveness (match must cover all enum variants; when guard conditions; wildcard _ for catch-all; nested tuple and record destructuring in match arms; 4–8 hrs invisible); provide interface design (functions not listed in provide are private; module consumers cannot access private bindings; module interface evolution requiring provide additions; 4–7 hrs invisible).

What are typical Grain developer retainer rates?

Entry-level Grain developers (1–2 years, basic functional patterns, standard library usage, grain compile/run workflow) bill at $60–$110/hr. Mid-level Grain functional programmers (2–4 years, Box mutability design, persistent data structure chaining, module interface design with provide, WebAssembly optimization) bill at $95–$175/hr. Senior Grain WebAssembly developers (4–8 years, WasmI32/WasmI64 type interop, Memory module linear memory management, wasm-opt pipeline tuning, production WASM application architecture) bill at $140–$255/hr. Monthly retainer ranges: $2,500–$4,500/mo advisory (15–25 hrs), $6,500–$16,000/mo for full Grain WebAssembly systems engineering.

What should a Grain developer retainer agreement include?

A Grain developer retainer agreement should specify: mutability scope (Box.make/Box.unbox/Box.:= for explicit heap mutation; persistent List/Map/Set return-value chaining; let rebinding semantics vs Box in-place update; when to use Array for O(1) mutable indexed access); module system scope (module declarations; provide keyword for export control; use and from...import syntax; standard library module selection; module aliasing); pattern matching scope (exhaustive match on enum variants; when guard conditions; nested destructuring; wildcard fallthrough); WebAssembly scope (grain compile/run/pack workflow; WasmI32/WasmI64 for low-level WASM type access; Memory module for linear memory; wasm-opt optimization flags); and hour logging format (advisory category: Box mutability, persistent data, module interface, pattern match, WASM interop; specific binding, data structure, and before/after stale-access or type-error count).

How should Grain developer retainer hours be logged?

Log each Grain retainer session with: advisory category (Box: Box.make, Box.unbox, Box.:= in-place update, Box vs let rebinding; persistent data: List.map return value, pipe chaining, Map.set new map, stale binding diagnosis; module: provide interface, import selection, module aliasing; pattern match: exhaustive variants, when guard, nested destructuring; WASM: WasmI32 type, Memory module, wasm-opt flags); the specific binding name, data structure operation, and before/after stale-access count (function: accumulate; original: let result = List.map(transform, items) — result unused, items continues to be read as stale; stale accesses: 3/function; fix: let items = items |> List.map(transform) or Box accumulator with Box.:= update; stale accesses: 3/function → 0); and the before/after metric. Include whether fix required pipe chaining for persistent updates, Box for true mutation, Array for indexed mutation, or module interface restructuring.