WASM Quick Start
Build WASM-accelerated functions in TJS with zero toolchain setup.
No Emscripten. No Rust. No .wasm files. No webpack plugins. Write WASM inline, and the
compiled bytecode is embedded in your JavaScript output. Ship one .js file.
Why TJS for WASM?
The traditional WASM workflow:
- Install a C/Rust/Go toolchain
- Write code in a separate language
- Compile to
.wasm(configure a build system) - Load the
.wasmfile at runtime (async, CORS, bundler config) - Marshal data between the JS and WASM heaps (manual, error-prone)
- Ship multiple files
The TJS workflow:
- Write
wasm { }inside your function, with afallback { }in plain JavaScript - Run
tjs emit myfile.tjs > myfile.js - Ship the output
.js
The compiler handles bytecode generation, base64 embedding, memory, and typed-array marshaling. The output is a single self-contained JavaScript file.
Your First WASM Block
function add(! a: 0, b: 0):! 0 {
return wasm {
return a + b
} fallback {
return a + b
}
}
test 'computed in WASM' {
expect(add(3, 4)).toBe(7)
}
What's happening:
wasm { }is a JavaScript block compiled to WASM bytecode at transpile time. Like any JavaScript block it returns only withreturn: a barea + bwould compute a value and throw it away, so the compiler refuses it.fallback { }is the same computation in plain JavaScript, for a runtime that cannot run WASM (noWebAssembly, or no compiler available). Both must give the same answer. A block the compiler cannot compile is a compile error, not a quiet fallback (see What compiles to WASM).!marks the function unsafe: its arguments are not type-checked on each call. For a tiny kernel that check can cost more than the work.:!declares the return type without a worked example. A plain: 0would be checked as a worked example (addwould have to return0), which is not what this function does.
Running it
TJS's command-line tool runs on Bun. In a project:
bun add -d tjs-lang
bunx tjs run myfile.tjs # transpile and run
bunx tjs emit myfile.tjs > myfile.js # emit standalone JavaScript
node myfile.js # the output runs anywhere: WASM is embedded as base64
What the Compiler Produces
For add above, the emitted JavaScript carries the compiled function as a comment in
WebAssembly text (WAT), followed by the embedded module:
/**
* WASM: __tjs_wasm_1wub6pu_0 (export: compute_0)
* (func (export "compute") (param $a f64) (param $b f64) (result f64)
* local.get $a
* local.get $b
* f64.add
* return
* )
*/
The module is instantiated synchronously when the file loads, so the first call already runs in WASM. (If an engine refuses synchronous compilation, it retries asynchronously and uses the fallback until it is ready.) The WAT comment is generated from the bytecode actually emitted, so it is a reliable view of what will run.
Numeric Types
Parameters take their WASM type from their TJS annotation:
| TJS annotation | WASM type | Example |
|---|---|---|
0, +0 (integer) |
f64 |
function f(x: 0) |
0.0 (number) |
f64 |
function f(x: 0.0) |
Float32Array (and other typed arrays) |
i32 (a pointer into WASM memory) |
function f(arr: Float32Array, len: 0) |
Integer parameters are f64 on purpose. A TJS integer is any whole number JavaScript
represents exactly (up to 2⁵³), and f64 holds all of them with exactly JavaScript's
arithmetic. A 32-bit i32 would wrap on overflow (2e9 + 2e9 would be negative) and trap
on division by zero, so the WASM path and the fallback would disagree.
Inside a block, let i = 0 declares an i32, which is what loop counters and array
offsets want. / always means JavaScript's division, even with two integer operands:
7 / 2 is 3.5 in WASM as in JavaScript. For integer division, write Math.trunc(a / b).
% and the bitwise operators need integer operands; for 32-bit integer algorithms (hashes,
random-number generators) declare i32 parameters on a wasm function (WASM in
TJS), which has WebAssembly's wrapping semantics.
SIMD: four floats per instruction
f32x4_* intrinsics operate on four float32 values at once:
function scale(! arr: Float32Array, len: 0, factor: 0.0) {
wasm {
let s = f32x4_splat(factor)
for (let i = 0; i < len; i += 4) {
let off = i * 4
let v = f32x4_load(arr, off)
f32x4_store(arr, off, f32x4_mul(v, s))
}
} fallback {
for (let i = 0; i < len; i++) arr[i] *= factor
}
}
test 'scales every element' {
const data = new Float32Array([1, 2, 3, 4, 5, 6, 7, 8])
scale(data, 8, 10.0)
expect(Array.from(data)).toEqual([10, 20, 30, 40, 50, 60, 70, 80])
}
The loop steps by 4, so len must be a multiple of 4. Handle a remainder with a scalar
loop (see Patterns). The intrinsics map directly to WASM SIMD opcodes: no
auto-vectorization, no surprises.
Available SIMD intrinsics
| Intrinsic | Operation |
|---|---|
f32x4_load(arr, byteOffset) |
Load 4 floats from memory |
f32x4_store(arr, byteOffset, vec) |
Store 4 floats to memory |
f32x4_splat(value) |
Fill all 4 lanes with one value |
f32x4_add, f32x4_sub, f32x4_mul, f32x4_div (a, b) |
Lane-wise arithmetic |
f32x4_min, f32x4_max (a, b) |
Lane-wise minimum / maximum |
f32x4_neg(v), f32x4_sqrt(v) |
Negate / square root of every lane |
f32x4_eq, f32x4_ne, f32x4_lt, f32x4_le, f32x4_gt, f32x4_ge (a, b) |
Lane-wise comparison (a mask) |
f32x4_select(a, b, mask) |
Per lane, a where the mask is set, else b |
f32x4_extract_lane(vec, N) |
One float from lane N (0–3) |
f32x4_replace_lane(vec, N, val) |
Set lane N |
Memory
Regular typed arrays: copied in and out
Pass a normal Float32Array and TJS copies it into WASM memory before the call and back
afterwards:
function double(! arr: Float32Array, len: 0) {
wasm {
for (let i = 0; i < len; i += 4) {
let off = i * 4
let v = f32x4_load(arr, off)
f32x4_store(arr, off, f32x4_add(v, v))
}
} fallback {
for (let i = 0; i < len; i++) arr[i] *= 2
}
}
test 'copy-out happens automatically' {
const data = new Float32Array([1, 2, 3, 4])
double(data, 4)
expect(Array.from(data)).toEqual([2, 4, 6, 8])
}
That is convenient, but on a large array the copy can cost more than WASM saves, and the
result is SLOWER than the plain JavaScript fallback. When that happens the runtime records a
notice; read them with __tjs.records({ source: 'wasm' }).
wasmBuffer(): zero-copy shared memory
For hot paths with large arrays, allocate the array in WASM memory to begin with:
const positions = wasmBuffer(Float32Array, 1024)
function shift(! arr: Float32Array, len: 0, delta: 0.0) {
wasm {
let vd = f32x4_splat(delta)
for (let i = 0; i < len; i += 4) {
let off = i * 4
f32x4_store(arr, off, f32x4_add(f32x4_load(arr, off), vd))
}
} fallback {
for (let i = 0; i < len; i++) arr[i] += delta
}
}
test 'WASM and JavaScript see the same memory' {
for (let i = 0; i < 1024; i++) positions[i] = i
shift(positions, 1024, 0.5)
expect(positions[0]).toBe(0.5)
expect(positions[1023]).toBe(1023.5)
}
How it works: all WASM blocks in a file share one WebAssembly.Memory (64MB).
wasmBuffer is a bump allocator that hands out views into it. When a typed array's
.buffer is the WASM memory's buffer, the wrapper skips the copy and passes the byte offset.
Supported types: Float32Array, Float64Array, Int32Array, Uint8Array.
Trade-off: wasmBuffer allocations are permanent (a bump allocator, no free). Use them
for long-lived buffers, not temporary scratch space.
What compiles to WASM
A wasm { } block is a JavaScript subset: numeric locals (let), arithmetic and comparison,
if, for, return, typed-array element access (arr[i]), the SIMD intrinsics above, and
Math.abs, ceil, floor, trunc, sqrt, min and max.
Anything else (an object, a string, a call to your own function, Math.sin) is a compile
error that names the construct: you asked for WASM, so it is not quietly shipped as
JavaScript. Rewrite the block within the subset, or drop the wasm { } wrapper to run it as
JavaScript. WASM in TJS has the full reference.
A block returns a number (f64) or nothing.
Patterns
Dot product (horizontal SIMD reduction)
function dot(! a: Float32Array, b: Float32Array, len: 0):! 0.0 {
return wasm {
let acc = f32x4_splat(0.0)
for (let i = 0; i < len; i += 4) {
let off = i * 4
acc = f32x4_add(acc, f32x4_mul(f32x4_load(a, off), f32x4_load(b, off)))
}
return f32x4_extract_lane(acc, 0) + f32x4_extract_lane(acc, 1)
+ f32x4_extract_lane(acc, 2) + f32x4_extract_lane(acc, 3)
} fallback {
let sum = 0
for (let i = 0; i < len; i++) sum += a[i] * b[i]
return sum
}
}
test 'dot product' {
const a = new Float32Array([1, 2, 3, 4, 5, 6, 7, 8])
const b = new Float32Array([1, 1, 1, 1, 2, 2, 2, 2])
expect(dot(a, b, 8)).toBe(62)
}
Rescale an array to [0, 1]
Find the minimum and maximum with scalar reads, then rescale four values at a time:
function rescale(! arr: Float32Array, len: 0) {
wasm {
let min = arr[0]
let max = arr[0]
for (let i = 1; i < len; i++) {
min = Math.min(min, arr[i])
max = Math.max(max, arr[i])
}
if (max > min) {
let lo = f32x4_splat(min)
let inv = f32x4_splat(1.0 / (max - min))
for (let i = 0; i < len; i += 4) {
let off = i * 4
f32x4_store(arr, off, f32x4_mul(f32x4_sub(f32x4_load(arr, off), lo), inv))
}
}
} fallback {
let min = arr[0]
let max = arr[0]
for (let i = 1; i < len; i++) {
min = Math.min(min, arr[i])
max = Math.max(max, arr[i])
}
if (max > min) for (let i = 0; i < len; i++) arr[i] = (arr[i] - min) / (max - min)
}
}
test 'rescales to [0, 1]' {
const data = new Float32Array([-10, 0, 10, 30])
rescale(data, 4)
expect(Array.from(data)).toEqual([0, 0.25, 0.5, 1])
}
Tips
- Always provide a
fallback. It is your safety net, and it makes the code testable without WASM. - Read the compile error when a block is refused: it names the construct outside the subset.
- Align to 4 elements for SIMD, or finish the remainder with a scalar loop.
- Use
wasmBufferfor large arrays in hot loops: copying a regular array in and out can cost more than WASM saves. - WASM blocks share memory per file: all blocks in one
.tjsfile use the same 64MBWebAssembly.Memory.
Limitations
- Calls inside a block are limited to the SIMD intrinsics and the
Mathfunctions above. To compose WASM across functions and files, usewasm functiondeclarations (WASM in TJS). - A block returns a number (
f64) or nothing. wasmBufferallocations are permanent (bump allocator).- SIMD is
f32only (f32x4): noi32x4orf64x2yet.
Next Steps
- WASM in TJS: the full reference, including
wasm functiondeclarations, cross-file composition and thetjs-lang/linalgkernels. - WASM Basics: integer math, floats, array processing.
- WASM SIMD: SIMD patterns and benchmarking.
- WASM Memory: marshaling and
wasmBuffer()in depth. - WASM Functions:
wasm functiondeclarations. - WASM Starfield: 50K SIMD-accelerated particles.
- WASM Vector Search: SIMD cosine similarity against JavaScript.