38.2 AssemblyScript: TypeScript-Like Syntax for Wasm
Alright, let’s talk about AssemblyScript. You’ve heard the siren song, haven’t you? “Write WebAssembly using TypeScript syntax! It’s easy!” And to its credit, it mostly is. But before you start porting your entire React app to Wasm expecting a 1000x speedup, let’s have a brutally honest chat about what this is, what it isn’t, and where it absolutely shines.
The core idea is devilishly clever: take a strict subset of TypeScript’s syntax, run it through a compiler (asc) that doesn’t target JavaScript but instead targets WebAssembly’s text format (WAT), and then binary-encodes it into a .wasm module. You’re not writing TypeScript that runs on a JavaScript VM; you’re writing a statically-typed language that looks like TypeScript and compiles to a completely different, low-level execution environment. This distinction is everything.
Why You’d Bother in the First Place
The “why” is performance, full stop. You use AssemblyScript to offload computationally expensive work—physics simulations, image processing, cryptography, complex data transformations—to a environment that executes it closer to native speed. JavaScript’s JIT is a marvel, but it has to make optimizations on the fly. WebAssembly, being a compiled target, allows for aggressive ahead-of-time optimizations. The other “why” is if you, like me, find the raw WAT syntax a bit like reading an alien assembly language and you’d prefer something that feels familiar.
The Strict Subtype You Didn’t Sign Up For
Here’s the first reality check: AssemblyScript is not TypeScript. It’s a different language that wears TypeScript’s skin. It enforces strictness that would make the TypeScript core team blush. This is necessary because every type must map directly to a WebAssembly data type.
For example, there’s no such thing as an any type. The universe would implode. You have precise, numeric types. Forget number; you’re dealing with i32 (32-bit integer), u32 (unsigned 32-bit integer), i64, f32 (32-bit float), and f64 (64-bit float). This is the first and most common pitfall for newcomers.
// This is valid TypeScript but will cause the AssemblyScript compiler to laugh at you.
function add(a: number, b: number): number {
return a + b;
}
// This is correct AssemblyScript. See the difference? Precision.
function add(a: i32, b: i32): i32 {
return a + b;
}
If you try to use the first version, asc will essentially say, “What in the hot heck is a number?” and throw an error. This is the price of admission.
The Memory Model: Where the Magic (and Pain) Lives
This is the most crucial concept to grasp. WebAssembly has a linear memory space, which is basically a giant, expandable array of raw bytes. AssemblyScript manages this memory for you, providing a garbage collector for its runtime objects (like Array<T> or String). However, you can also manually manage memory, which is often where the biggest performance gains are.
You have two primary ways to work with data:
- Managed Objects: Using high-level objects like
Array<string>. These are convenient but come with GC overhead. - Unmanaged Value Types: Working directly with primitives (
i32,f64) or using low-level classes likeArrayBufferto manually poke bytes in memory. This is faster but puts the burden of memory management on you.
// A simple managed array (easier, GC'd)
let managedArray: Array<i32> = [1, 2, 3, 4];
// Using an unmanaged ArrayBuffer for maximum speed (harder, manual)
let bufferSize = 16;
let unmanagedBuffer = new ArrayBuffer(bufferSize);
// Get a view into that buffer to work with 32-bit integers
let int32View = Int32Array.wrap(unmanagedBuffer);
int32View[0] = 42;
// ... remember, this memory isn't automatically freed if you do this a lot!
The biggest pitfall here is forgetting that passing complex objects between JavaScript and WebAssembly isn’t free. You can’t just pass a JavaScript object. You have to serialize data into the linear memory (e.g., writing strings as sequences of bytes) and then pass pointers to that memory location. The @assemblyscript/loader package provides utilities to help with this, but it’s still manual labour.
The JavaScript Interop Dance
Calling AssemblyScript from JavaScript feels like a diplomatic negotiation between two powerful but suspicious nations. You have to be explicit about the terms.
First, you compile your .ts file: npx asc myModule.ts --target release. This spits out a myModule.wasm file and, crucially, a myModule.wat file if you want to see the generated “assembly” (which I highly recommend for learning).
Then, on the JavaScript side, you instantiate the module and start talking to it.
// In your JS code
import { instantiate } from "@assemblyscript/loader";
// You'd typically do this asynchronously
const wasmModule = await instantiate(
fetch("path/to/myModule.wasm"),
/* imports */ { /* ... */ }
);
// Now you can call your exported functions!
const result = wasmModule.exports.add(25, 17);
console.log(result); // 42
The imports object is how you give your AssemblyScript code access to JavaScript functions, like console.log or your own custom debug tools. It’s a two-way street. You explicitly export functions from AssemblyScript and explicitly import JavaScript functions into it. There’s no implicit sharing, which is actually great for security and reasonability.
Best Practices and When to Reach for It
So, is it worth it? Absolutely, for the right job.
- Best Practice: Profile your JS first. Don’t guess. Find the hot path—the function that’s taking 90% of your CPU time—and then consider porting it to AssemblyScript.
- Best Practice: Start small. Port a single, pure function that does heavy number crunching. Get the build pipeline and interop working there first.
- Best Practice: Use the
@inlinedecorator liberally for small, hot functions to avoid the function call overhead. The compiler is good, but sometimes you need to give it a nudge. - Edge Case: Be hyper-aware of data marshaling costs. The process of converting a large JavaScript array into Wasm memory and back can easily erase any performance gains from the calculation itself. Sometimes it’s better to manage the data inside Wasm land and only return a simple result.
AssemblyScript is a fantastic tool that makes WebAssembly accessible. It removes the massive barrier to entry that is learning a whole new systems-level language like Rust or C++. Just remember: you’re not writing TypeScript. You’re writing a lean, mean, statically-typed machine that just happens to look reassuringly familiar. Embrace the strictness; it’s what makes it fast.