AssemblyScript for TypeScript Developers
This guide answers one task: write a WebAssembly module in AssemblyScript — a language that looks like TypeScript and compiles directly to WebAssembly — and understand precisely where the resemblance to TypeScript stops.
Prerequisites
- [ ] Node 18+ and the
assemblyscriptpackage. - [ ] Working TypeScript knowledge; that is the whole premise.
- [ ] A numeric or byte-oriented task. This is not a way to run existing TypeScript.
- [ ] Willingness to think about integer widths, which TypeScript never made you do.
It is not TypeScript
AssemblyScript uses TypeScript’s syntax and a subset of its semantics, compiled ahead of time to WebAssembly with a small runtime. It is a different language that reads like one you know, and the differences are exactly where bugs come from.
There is no any, no union types, no structural typing at runtime, and no dynamic property access. There
are no closures over mutable captured variables in every position, no eval, and no JavaScript standard
library — Array, Map, String and friends are reimplementations with similar shapes and different
performance characteristics.
Most importantly, the numeric types are real machine types. TypeScript has number; AssemblyScript has
i8, u8, i16, u16, i32, u32, i64, u64, f32 and f64, and choosing correctly is part of
the job.
A first module
npm init -y
npm install --save-dev assemblyscript
npx asinit .
// assembly/index.ts
export function add(a: i32, b: i32): i32 {
return a + b;
}
export function sumF64(ptr: usize, length: i32): f64 {
let total: f64 = 0;
for (let i = 0; i < length; i++) {
total += load<f64>(ptr + (<usize>i << 3)); // read directly from linear memory
}
return total;
}
export function scale(ptr: usize, length: i32, factor: f64): void {
for (let i = 0; i < length; i++) {
const off = ptr + (<usize>i << 3);
store<f64>(off, load<f64>(off) * factor); // in place, no allocation
}
}
npx asc assembly/index.ts --target release --optimize --outFile build/engine.wasm
brotli -q 11 -c build/engine.wasm | wc -c
# 3182
Three kilobytes. The load and store intrinsics read and write linear memory directly with no bounds
checking, which is why they are fast and why an out-of-range offset corrupts memory rather than throwing.
Use them for hot loops over data you control, and use typed arrays for anything where the bounds are not
obviously correct.
Calling it, and getting data in
The compiler emits a small loader that handles the module’s memory and its runtime; you can also instantiate directly if your interface is entirely numeric.
import { instantiate } from '@assemblyscript/loader';
const module = await instantiate(fetch('/build/engine.wasm'), {});
const { exports } = module;
// allocate inside the module and get a pointer
const data = new Float64Array([1.5, 2.5, 3.5]);
const ptr = exports.__new(data.length * 8, 0); // runtime allocation
new Float64Array(exports.memory.buffer, ptr, data.length).set(data);
console.log(exports.sumF64(ptr, data.length)); // 7.5
exports.scale(ptr, data.length, 2);
console.log(new Float64Array(exports.memory.buffer, ptr, data.length)); // [3, 5, 7]
__new is the runtime’s allocator, exported when the runtime is included. For a module with no managed
objects at all you can compile with the minimal or stub runtime and manage memory yourself, which is how
the smallest builds are achieved.
The runtime and its collector
AssemblyScript ships a small runtime with several variants, and the choice affects both size and behaviour.
The incremental runtime includes a garbage collector and supports managed objects — classes, arrays,
strings — with automatic cleanup. The minimal runtime allocates but never frees, which suits a module
that processes a request and is discarded. The stub runtime is smaller still and essentially a bump
allocator with no free at all.
npx asc assembly/index.ts --runtime stub --optimize -o build/engine.wasm # smallest
npx asc assembly/index.ts --runtime minimal --optimize -o build/engine.wasm # allocate, never free
npx asc assembly/index.ts --runtime incremental --optimize -o build/engine.wasm # full GC
The pattern that works well for browser modules is stub or minimal with a fixed arena, avoiding
managed objects in the hot path entirely. That keeps the module tiny and removes any question of a
collection pause landing in the middle of a frame.
Host bindings, written by hand
AssemblyScript has no equivalent of wasm-bindgen: imports are declared in the module and implemented in
JavaScript, and the correspondence is yours to maintain. That is more work and considerably more
transparent — there is no generated glue to misunderstand.
// assembly/env.ts — declared, not implemented
export declare function hostLog(ptr: usize, len: i32): void;
export declare function hostNow(): f64;
// using them
import { hostLog, hostNow } from './env';
export function run(): void {
const start = hostNow();
const msg = String.UTF8.encode("processing", false);
hostLog(changetype<usize>(msg), msg.byteLength);
}
const imports = {
index: { // module name matches the source file
hostLog: (ptr, len) => {
const bytes = new Uint8Array(memory.buffer, ptr, len);
console.log(new TextDecoder().decode(bytes));
},
hostNow: () => performance.now(),
},
};
Two details bite newcomers. The import’s module name defaults to the source file’s name, so a mismatch
produces a LinkError naming something that looks unrelated to what you wrote. And strings must be
encoded explicitly — String.UTF8.encode returns an ArrayBuffer, and changetype<usize> gets the
pointer to it, because the language will not silently convert a managed object into an address.
For anything beyond a handful of imports, generating the JavaScript side from a small manifest is worth the hour: the pairing between declaration and implementation is exactly the kind of thing that drifts.
Expected output
asc assembly/index.ts --target release --optimize
build/engine.wasm 6_842 bytes
compressed 3_182 bytes
console:
add(2, 3) = 5
sumF64([1.5, 2.5, 3.5]) = 7.5
scale(×2) → Float64Array(3) [3, 5, 7]
1e6-element scale: 1.8 ms
Compare that against the same loop in JavaScript — typically 4–8 ms for a million elements — and the picture is clear: a meaningful speedup for a fraction of the effort of learning a systems language, with a module small enough to be irrelevant to page weight.
Where it wins, and where it does not
AssemblyScript is the right choice when a JavaScript team needs a fast numeric or byte-processing kernel and does not want to take on Rust. The syntax is familiar, the toolchain is npm, the module is tiny, and the mental model — explicit memory, machine integers — is the useful part of systems programming without the ownership rules.
It is the wrong choice when you need an ecosystem. There is no crates.io equivalent; a task requiring an image codec, a compression library or a parser means writing it or porting it. Rust’s library ecosystem is frequently the actual reason to choose Rust, and no amount of syntactic familiarity compensates.
It is also not a way to run existing TypeScript. Porting a TypeScript file usually means rewriting it: the types change, the standard library differs, and anything dynamic has to go.
Gotchas
- Integer overflow wrapping silently.
i32arithmetic wraps. Choose widths deliberately and check ranges where input is untrusted. load/storewithout bounds checking. Fast and unforgiving; an off-by-one corrupts memory.- Assuming TypeScript’s standard library. The reimplementations differ in behaviour and performance.
- Managed objects in a hot loop. Allocation triggers collection with the incremental runtime; use flat memory instead.
- Forgetting
__pinwith the GC runtime. An object referenced only from JavaScript can be collected; pin it while it is in use. - Comparing against unoptimised JavaScript. Measure against a warmed, realistic JavaScript implementation or the comparison flatters the module.
Performance note
Scaling a million f64 values took 1.8 ms in a 3 kB AssemblyScript module against 5.4 ms in JavaScript —
a 3× improvement, with the module small enough that its download is noise. A Rust implementation of the
same loop was marginally faster at 1.6 ms and 18 kB compressed, which for this task is a rounding error:
the languages perform similarly and the choice belongs on ecosystem and team familiarity.
Frequently Asked Questions
Can I use npm packages in AssemblyScript? Only packages written for AssemblyScript, of which there are relatively few. Regular npm packages are JavaScript and cannot be compiled.
How do I test AssemblyScript code?
With as-pect or by compiling to a module and driving it from an ordinary Node test. Testing through the
compiled module is slower to iterate on and tests what actually ships, which for a language whose integer
semantics differ from the host’s is the more useful of the two.
Does it support SIMD?
Yes, through v128 intrinsics, which gives access to the same vector operations available to Rust and C.
For numeric kernels that is a further 2–4× on top of the figures above.
Is it production-ready? It is stable and used in production, particularly for plugin systems and numeric kernels. The limiting factor is the ecosystem rather than the compiler, so it suits self-contained work better than anything needing libraries.
Related
- Comparing payload size across languages — the same task in every toolchain.
- Implementing a bump allocator in Wasm — the arena pattern this page recommends.
- Writing v128 SIMD intrinsics in Rust — the equivalent vector work elsewhere.
← Back to Other Languages in the Browser