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 assemblyscript package.
  • [ ] 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.

One number type becomes ten TypeScript exposes a single double-precision number type. AssemblyScript exposes the machine's integer and floating-point types, which is where its speed comes from and where its unfamiliar failure modes live. TypeScript number — always f64 no overflow, no width safe, and slower AssemblyScript i8 u8 i16 u16 i32 u32 i64 u64 f32 f64 wraps on overflow, like the machine fast, and your responsibility An i32 that overflows wraps silently, exactly as it would in C. This is the single biggest adjustment for someone arriving from TypeScript.

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.

Pick the smallest runtime that does the job The stub runtime allocates without freeing and is smallest. The minimal runtime adds basic management. The incremental runtime includes a garbage collector and supports the full object model at a larger size. stub bump allocate, never free ≈ 1–3 kB one-shot modules minimal manual free, no collector ≈ 3–6 kB arena-style workloads incremental full garbage collection ≈ 8–14 kB long-lived object graphs Even the largest is a fraction of what a hosted language ships, which is what keeps AssemblyScript in the compiled-directly group.

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.

Familiar syntax, different rules The syntax is TypeScript's, the semantics are not. Types are concrete, numbers are machine numbers, and the standard library is a small one written for this target. numeric types i32, f64 and friends — not one number type no any, no union types every value has one concrete layout its own standard library a subset; most npm packages cannot be used a small collector included in the payload, tens of kilobytes Treat it as a new language with familiar syntax; porting TypeScript unchanged rarely compiles. The payoff is a small binary and a toolchain a JavaScript team can already operate.

Gotchas

  • Integer overflow wrapping silently. i32 arithmetic wraps. Choose widths deliberately and check ranges where input is untrusted.
  • load/store without 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 __pin with 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.

← Back to Other Languages in the Browser