[{"url":"/","page":"Structs","section":null,"text":"This is an implementation of the TC39 structs proposal (Stage 2, the December 2024 draft) for JavaScript today: structs, shared structs, Shared Arrays, Atomics.Mutex and Atomics.Condition. It has a runtime that follows the spec's algorithms, and a compiler for the proposal's syntax. In TypeScript, the compiler also uses the types to make shared struct field access direct. shared struct Counter { count = 0; lock = new Atomics.Mutex(); } const counter = new Counter(); worker.postMessage(counter); // the same object in the worker, not a copy // in any agent: { using _ = Atomics.Mutex.lock(counter.lock); counter.count++; } You can write programs and run them in your browser, with the compiled JavaScript side by side, in the playground. Status: experimental This grew out of arv/threads, as something much smaller that stays as close to the spec as JavaScript allows. See Differences from the Spec."},{"url":"/#whats-in-it","page":"Structs","section":"What's in It","text":"Spec Here §1 struct declarations The compiler lowers them to classes that call the runtime §2 shared struct declarations Proxies over one SharedArrayBuffer, the heap §2.3 Atomics on struct fields load, store, exchange and compareExchange take a struct §2.4 Reflect.canBeShared Installed on Reflect §3 SharedArray A global: a Shared Struct with indexed fields and a fixed length §4 Atomics.Mutex, UnlockToken Futex-style locks on heap words; tokens work with using §4 Atomics.Condition wait, waitFor (with a timeout and a predicate), notify Importing @arv/structs installs the globals. The compiler's output imports @arv/structs/runtime, which does it too."},{"url":"/#where-to-next","page":"Structs","section":"Where to Next","text":"Installing and Running: the packages, the Node hook, the CLI and the compiler's API. Structs and Shared Structs: what the syntax makes, and how. The Playground: try it all in your browser."},{"url":"/differences-from-the-spec/","page":"Differences from the Spec","section":null,"text":"Where JavaScript can't do what the spec says, or where the spec text looks like an oversight, this implementation does something else: Memory is never reclaimed. Shared objects and interned strings live until the process ends. There is no collector across agents. Symbols. Only registered (Symbol.for) and well-known symbols can be shared, as values or as field keys, because other agents don't have the others. Reflect.canBeShared returns false for the rest. [[GetOwnProperty]] reports shared fields as writable. The spec says not writable, but its own [[DefineOwnProperty]] asserts writable, and a Proxy can't report a writable target property as non-writable. Constructors are classes. A shared struct's constructor has a prototype object (with a null prototype; its instances still have none). A struct with no extends has a hidden root function as its constructor's [[Prototype]], not Function.prototype. Arguments. The spec evaluates the arguments of new S(...) and of super(...) but never passes them to the initializer. Here they are passed. Methods are methods. The spec wraps each instance method in a struct method exotic object, which checks this and has no own properties. Here the check is the method's first statement, so it keeps its name and length. A method whose parameters run code (a default, a destructuring) is called through another that checks first. Getters and setters check this too. The spec checks only methods: its DefineMethod makes no struct method of an accessor. Here an instance getter or setter throws a TypeError on what is not an instance of its struct, as a method does. Static methods are not brand-checked. The spec's DefineMethod would wrap them too, which would make them impossible to call. super.x in a shared struct's initializers looks at the parent constructor. In the spec their home object is null, and super.x throws. Mutexes aren't fair. A woken waiter competes with newcomers, where the spec has a waiter queue. Logging a shared struct in Node Node's console.log shows a Proxy's target, so it prints a shared struct's fields as undefined. Spread it instead: console.log({...s})."},{"url":"/mutex-and-condition/","page":"Mutex and Condition","section":null,"text":"Atomics.Mutex and Atomics.Condition are shared structs with no fields, so they can be stored in another shared struct's fields and sent to workers. A lock gives an UnlockToken, which using unlocks at the end of its block. A Condition waits with that token: it unlocks the Mutex while it waits and locks it again before it returns. shared struct Queue { items = new SharedArray(16); size = 0; lock = new Atomics.Mutex(); changed = new Atomics.Condition(); } // on a worker: wait until there is something to take { using token = Atomics.Mutex.lock(queue.lock); Atomics.Condition.waitFor(queue.changed, token, Infinity, () => queue.size > 0); // take an item, then tell the others Atomics.Condition.notify(queue.changed); } Atomics.load, store, exchange and compareExchange also take a shared struct and a field name (§2.3), for lock-free code on its fields. The code is in src/runtime/sync.ts and src/runtime/atomics.ts."},{"url":"/repository/","page":"This Repository","section":null,"text":"@arv/structs is developed in arv/structs. Here's what's in it: Path What it is src/runtime/heap.ts The heap: allocation, locks, interned strings src/runtime/shared.ts Shared Structs and Shared Arrays: slots, shapes, Proxies src/runtime/structs.ts What struct declarations become src/runtime/sync.ts Mutex, UnlockToken, Condition src/runtime/atomics.ts The Atomics overloads, Reflect.canBeShared src/runtime/agent.ts Workers on the same heap, messages with shared objects src/runtime/emit.ts What compiled code imports src/compiler/scan.ts Finds struct declarations and spells them as classes src/compiler/transform.ts Lowers them src/compiler/project.ts TypeScript projects: type checking src/compiler/fast.ts Direct field access where the types allow it src/compiler/register.ts Node's load hook playground/ The playground: edit, compile and run in the browser site/ These docs as a site, built by Eleventy The compiler parses with TypeScript 7's API (the same parser in both modes), edits with magic-string, and strips types with OXC."},{"url":"/repository/#development","page":"This Repository","section":"Development","text":"The repository is a pnpm workspace on Vite+ (vp): the runtime at the root, the compiler in src/compiler/, the playground and these docs in folders of their own. git clone https://github.com/arv/structs.git cd structs && pnpm install vp check vp test vp check formats, lints and type checks. vp test runs the tests in test/, including workers that share the heap, and the site's own: every link of these docs leads somewhere, and every code block and table of the repository's Markdown is in them. vp run test-pack packs both packages, installs them with npm in a project of its own, and uses them there. The examples run from the repository with the load hook's source: node --import ./src/compiler/register.ts examples/counter/main.js Pushing a version tag (v0.1.0) publishes both packages to npm (release.yml)."},{"url":"/repository/#license","page":"This Repository","section":"License","text":"Copyright 2026 Erik Arvidsson. Licensed under the Apache License 2.0."},{"url":"/running-it/","page":"Installing and Running It","section":null,"text":"There are two packages: the runtime, and the compiler for the proposal's syntax, which you need only to build or run code that has it. npm install @arv/structs npm install --save-dev @arv/structs-compiler In Node, the compiler's load hook compiles struct syntax as modules load, in workers too: node --import @arv/structs-compiler/register main.js The structs command compiles a single file, or a whole TypeScript project with type checking: npx structs transpile main.js npx structs compile -p tsconfig.json compile writes each file to the tsconfig's outDir (or build/), with its source map. --no-fast leaves every shared struct field access to the Proxy (see TypeScript), and --typescript keeps the types. From code, use transpile(source, {fileName}) and compile(tsconfigPath) from @arv/structs-compiler. The compiler runs on TypeScript 7 It parses with TypeScript 7's API, the same parser for JavaScript and for TypeScript, which starts tsgo, TypeScript's compiler, as a process. The playground runs the same compiler on tsgo in WebAssembly."},{"url":"/shared-structs/","page":"Shared Structs","section":null,"text":"Every agent attaches to one SharedArrayBuffer of a fixed size (256 MB, which is reserved rather than used up front). A Shared Struct is a block in it with a slot per field. Each agent sees the block through a Proxy whose traps are the spec's internal methods (§2.1.5 to §2.1.10). There is one Proxy per block per agent, so === holds. a shared object [kind][shape: its keys, interned][..][..] then 16 bytes per field a slot [a tagged word: the value][a Float64, for a Number] A field holds undefined, null, a boolean, a Number, a string (interned in the heap), a BigInt, a registered or well-known symbol, or another shared object. Anything else is a TypeError (CanBeSharedAcrossAgents, §2.2.4). A read never tears: a Number's Float64 is written before its word, and the word is stored atomically. Mutex and Condition are Shared Structs with no fields. Their internal slots are heap words that Atomics.wait and Atomics.notify work on. Blocking where an agent cannot (a browser's main thread) is a TypeError, as the spec says (AgentCanSuspend). The code is in src/runtime/heap.ts (the heap) and src/runtime/shared.ts (slots, shapes and the Proxies)."},{"url":"/sharing-across-agents/","page":"Sharing Across Agents","section":null,"text":"The proposal leaves sharing across agents to the host. @arv/structs/agent starts workers that attach to the same heap: worker_threads in Node, module Web Workers in a browser. Messages sent through it carry shared objects as themselves, and everything else as postMessage copies it. // main.js import {startWorker} from '@arv/structs/agent'; const w = startWorker(new URL('./worker.js', import.meta.url)); w.postMessage({tally, n: 1000}); // worker.js import {parent} from '@arv/structs/agent'; parent.onmessage = ({tally, n}) => { /* tally is the main thread's struct */ }; run(url, name, ...args) calls the function a module exports as name on a worker and settles with what it returns. A module can run its own functions with run(import.meta.url, ...). The workers are a pool: one that returns waits for the next call, with the code V8 has optimized in it, so a program that runs its work again finds its workers warm. Each worker imports the module, so its top-level code runs there too: keep what only the main thread does in a function, or behind if (!parent). examples/counter has four workers counting to a million under a Mutex, with a Condition saying when they are done."},{"url":"/structs/","page":"Structs","section":null,"text":"A struct is a class with a different construction. The compiler keeps the class, which gives the struct its scope, private names and static elements. It moves the fields and the constructor into the runtime's hands (src/runtime/structs.ts shows the shape). An instance is made the spec's way (§1.1.6): All of its fields, the base struct's first, are defined as undefined, and it is sealed. The field initializers run, from the base struct down. The constructor runs. It is an initializer, and super(...) calls the parent struct's initializer. Instance methods check that this is an instance of their struct (struct method exotic objects, §1.2), and so do getters and setters here. Constructing anything but the struct itself is a TypeError, so a class cannot extend a struct. No constructor, no initializer A struct with no constructor has no initializer, so its parent's initializer doesn't run. That's the spec's behavior. Classes do the opposite."},{"url":"/the-playground/","page":"The Playground","section":null,"text":"The playground is a page where you write programs in the proposal's syntax, see what they compile to, and run them on Web Workers that share memory. Its source is in playground/."},{"url":"/the-playground/#using-the-playground","page":"The Playground","section":"Using the Playground","text":"Pick a program from the Program menu and edit it in Monaco. As you type, the page compiles the program and shows you: The JavaScript. Direct field access shows what the compiler emits where TypeScript proves an object is an instance of a shared struct: the field is read and written in shared memory, without the Proxy. Proxy only shows the same program with every field access going through the Proxy. The runtime's calls are marked, and the direct accesses are in bold. The errors. These are TypeScript's, from seeing each struct as a class, and the structs' own: a shared struct can't have methods, for example. They appear as squiggles in the editor and in a list. The output. Run calls the program's main(n) on a worker, once for each count in the Workers field, which starts out as 1, 2, 4 and 8, and says how long each took and how much faster than the first: the way to see that more workers make it faster. Warm up, which is on, runs main once on the most workers first, unshown: each worker's JavaScript engine optimizes its hot code as it runs, and the workers are kept for the next call, so the timed runs compare workers that are all warm. main may block, on Atomics.Mutex.lock for example. A program starts its own workers with run(import.meta.url, 'name', ...args) from the agent module (src/runtime/agent.ts), which calls the function it exports as name on a new worker. Their console output comes back to the page. Stop ends the run, along with the workers it started. Monaco's TypeScript can't parse struct, so the editor only colors and completes. The errors you see come from the playground's compiler. The page remembers your edits to each program in this browser. Reset puts the original back."},{"url":"/the-playground/#run-it-locally","page":"The Playground","section":"Run It Locally","text":"You need Node 22.13 or later and a recent Chrome. A static build also needs Go 1.27 or later, and git. vp install vp dev playground On the dev server, programs compile in Node, on TypeScript's native API. Add ?wasm to the address to compile in the browser instead."},{"url":"/the-playground/#static-builds","page":"The Playground","section":"Static Builds","text":"Without a dev server, the page compiles programs itself. It runs the same compiler, in a worker, on tsgo (stock TypeScript 7.1) compiled to WebAssembly, in a worker of its own. The two talk over pipes in shared memory. You get the same JavaScript and the same errors as on the dev server. The catch is size: tsgo is 46 MB, about 10 MB gzipped, and the page shows the download as it goes. vp run build-wasm vp build vp run test-build build-wasm builds tsgo from TypeScript's sources at the commit of the TypeScript the compiler runs on, into public/compiler/. vp build builds the page into dist/, to be served from /playground/ (set BASE for another path). test-build serves the build with no COOP or COEP headers, so the service worker has to isolate it. It then compiles and runs every program in Chrome, in both modes, and checks that the JavaScript, the errors and the output match the dev server's. Cross-origin isolation SharedArrayBuffer needs a cross-origin isolated page, which takes the COOP and COEP headers. The dev and preview servers send them. Where a host can't, as on GitHub Pages, a service worker adds them, and the page loads once more under it."},{"url":"/the-playground/#programs","page":"The Playground","section":"Programs","text":"Each program exports main(n), which runs on n workers where it uses any: Structs: sealed, initialized in one shot, with brand-checked methods Shared structs across workers: a linked list that workers add to Atomics.Mutex: workers counting together Atomics.Condition: a bounded queue between producers and a consumer Atomics on fields: claiming work with compareExchange Mandelbrot: rows claimed by workers, which scales with them, and the picture in 256 colors N-body: a thousand bodies split among workers, kept in step by a barrier built from a Mutex and a Condition N-body: direct field access against the Proxy The mistakes the compiler catches"},{"url":"/the-playground/#layout","page":"The Playground","section":"Layout","text":"Path What It Is index.html, main.ts The page, built on Monaco boot.ts The page's entry: it isolates the page, then loads main.ts programs/ The page's programs compile-program.ts A program compiled, on the dev server and in the browser alike vite.config.ts The dev server, which compiles programs, and the headers wasm.ts, compiler.ts Compiling in the browser: the page's side, and the compiler's worker link.ts A compiled program as a blob: module its workers can import too tsgo/ tsgo in WebAssembly, and the API client's channel to it build-wasm.ts, test-build.ts Building tsgo, and the static build tested in Chrome public/coi-sw.js The service worker that isolates the page where the host can't"},{"url":"/typescript/","page":"TypeScript","section":null,"text":"compile type checks a project as TypeScript sees it with each struct spelled as a class. It drops the errors that hold for a class but not for a struct, such as a derived constructor without super(). Then, where the checker proves an expression is an instance of a shared struct whose fields are all known, o.x and o.x = v skip the Proxy: const dx = a.x - b.x; // compiles to const dx = $struct.getNumber(a, $L0, 0) - $struct.getNumber(b, $L0, 0); The types are not trusted. The runtime checks that the object is a Shared Struct with that layout, and reads it through the Proxy if not, so a structurally typed plain object still works. Compiled code holds heap addresses rather than Proxies where it can. A binding that keeps its value (a const, or a let or a parameter that is never assigned) is checked once, beside the binding. A struct taken from a SharedArray<S> element or from a struct-typed field is the address that slot already holds, read without making its Proxy at all; the binding itself is made only on the slow path. Each function keeps the heap's views in locals, since the heap's size is fixed: const b = bodies[j]; const bx = b.x; // compiles to: b's address from bodies' slot, then the Float64 if it holds a Number const $a1 = $struct.elem($a0, j, $L0), b = $a1 < 0 ? bodies[j] : undefined; const bx = $a1 < 0 || $I[($a1 >> 2) + 4] !== 1 ? $struct.getNumberAt(b, $a1, $L0, 0) : $F[($a1 >> 3) + 3]; On examples/nbody (1,000,000 steps, Node 24, Apple Silicon), which keeps its bodies in a SharedArray<Body>, this takes 0.14 s. The same program on plain JavaScript objects takes 0.045 s, and through the Proxy alone 7.5 s. Most of what is left is the check of each slot's tag: a field may hold any shareable value, so a read checks that it holds a Number. The code is in src/compiler/project.ts (the type check) and src/compiler/fast.ts (the direct access)."}]