WASM in TJS
Canonical reference for WebAssembly in TJS — what you can write, how it's compiled, and how cross-file composition works.
For a 5-minute introduction, start with docs/WASM-QUICKSTART.md.
For the cross-file libraries design rationale, see wasm-library-plan.md.
Two flavors
TJS supports two related ways of writing WebAssembly:
| Flavor | Syntax | Use when |
|---|---|---|
| Inline block | wasm { ... } inside a regular function |
One-off acceleration of a hot inner loop in a single function; you want a JS fallback |
| Top-level declaration | wasm function NAME(...): T { ... } at module scope |
Reusable kernel that other files (or this one) may want to import |
Both flavors compile to the same WebAssembly.Module per file (one module per
file, multiple exported functions), and both share one linear memory.
Inline blocks
The original form. Captures (free variables) are auto-detected from the
enclosing scope and become wasm function parameters. Optional fallback { ... }
provides a JS path when SIMD or other features are unavailable.
function double(arr: Float32Array, len: 0) {
wasm {
for (let i = 0; i < len; i++) {
arr[i] = arr[i] * 2.0
}
} fallback {
for (let i = 0; i < len; i++) arr[i] *= 2
}
}
See docs/WASM-QUICKSTART.md for a thorough walkthrough.
Runtime: readiness, failures, and the enable toggle
Inline wasm{} blocks instantiate synchronously when the file loads (new WebAssembly.Module), so the first call already runs in WASM. The one exception is a browser
main thread with a module over 4KB, where the engine forbids synchronous compilation; there it
retries asynchronously and the fallback{} runs until it is ready.
- A block that cannot compile is a compile error (since 0.14), naming why: an unsupported
construct, a
%or bitwise operator on non-integers, a SIMD load from a plain array. Writingwasm { }is a request for WASM, so it is not quietly shipped as JavaScript. These refusals come from TJS's own compiler and do not depend on where you transpile. One backstop does: the emitted module is checked withWebAssembly.validate, which catches a TJS codegen bug, and a transpile host withoutWebAssembly(iOS Lockdown Mode) skips that check. A module that is invalid anyway fails to instantiate at load, runs itsfallback{}and is recorded (below). fallback{}is for a runtime that cannot run WASM: noWebAssemblyat all (iOS Lockdown Mode), no compiler available (an engine under memory pressure), or a refused instantiation. Each is recorded once in__tjs.records({ source: 'wasm' }).await globalThis.__tjs_wasm_ready()resolves once every emitted module has instantiated. Await it before the first call where the async path is possible.globalThis.__tjs_wasm_enabled = falseforces every block to run itsfallback{}even when the WASM is instantiated: an A/B toggle for "WASM vs JS" benchmarking. Set it back totrue(or delete it) to re-enable.
Top-level declarations
The Phase 1+ form. Declared with explicit parameters and return type, at module
scope. The function name is what consumers (in this file or other files) call.
No fallback {} block — these are pure wasm kernels.
export wasm function dot(a: Float32Array, b: Float32Array, n: i32): f64 {
let acc = f32x4_splat(0.0)
for (let i = 0; i < n; i += 4) {
let off = i * 4
let av = f32x4_load(a, off)
let bv = f32x4_load(b, off)
acc = f32x4_add(acc, f32x4_mul(av, bv))
}
return f32x4_extract_lane(acc, 0)
+ f32x4_extract_lane(acc, 1)
+ f32x4_extract_lane(acc, 2)
+ f32x4_extract_lane(acc, 3)
}
The transpiler replaces the declaration with a regular JS wrapper that forwards to the wasm export. Callers see a normal JavaScript function.
Parameter types
wasm function parameters use wasm type names, not TJS example-based
syntax. (Inside a wasm function, name: 'hello' would be a type error, not
an example value.)
| Annotation | WASM type | Notes |
|---|---|---|
name: i32 |
i32 |
32-bit signed integer |
name: i64 |
i64 |
64-bit signed integer |
name: f32 |
f32 |
32-bit float |
name: f64 |
f64 |
64-bit float |
name: number |
f64 |
Alias |
name: int |
i32 |
Alias |
name: Float32Array |
i32 (pointer) |
Pointer to f32 array in linear memory |
name: Float64Array |
i32 (pointer) |
Pointer to f64 array |
name: Int32Array |
i32 (pointer) |
Pointer to i32 array |
name: Uint8Array |
i32 (pointer) |
Pointer to u8 array |
The JS-side wrapper auto-marshals typed arrays: if you pass a regular
Float32Array, the wrapper copies it into wasm memory and back; if you pass a
wasmBuffer(Float32Array, n), the wrapper detects the shared backing buffer
and skips the copy (zero-copy fast path).
Return types
Current backend limitation: wasm functions return f64 or void only.
: f64 and omitted-return both work today. Other return types (: i32,
: f32, : v128) are parsed but not yet driving the emitted bytecode; the
backend always emits f64. This is a known limitation tracked in
wasm-library-plan.md as "Phase 1.5 work."
Practically: if your function would naturally return f32 (e.g., a SIMD reduce), return f64 and let the caller downcast. If you need integer results, return f64 and cast on the JS side.
SIMD intrinsics
v1 of the wasm-library plan requires SIMD baseline. All the f32x4
intrinsics from inline blocks work in wasm function bodies:
- memory / lanes:
f32x4_splat,f32x4_load,f32x4_store,f32x4_extract_lane,f32x4_replace_lane - arithmetic:
f32x4_add,f32x4_sub,f32x4_mul,f32x4_div,f32x4_neg,f32x4_sqrt,f32x4_min,f32x4_max - comparisons (return a v128 lane mask — all-1s where true, all-0s else):
f32x4_eq,f32x4_ne,f32x4_lt,f32x4_gt,f32x4_le,f32x4_ge - branch-free blend:
f32x4_select(mask, a, b)— picks laneawhere the mask lane is true,bwhere false (compiles tov128.bitselect).
The compare → mask → select trio is what makes data-dependent SIMD possible
(clamp/saturate, conditional blends, per-lane escape masking à la SIMD Mandelbrot)
— not just branchless arithmetic. Example, a branch-free clamp:
let lo = f32x4_splat(0.0)
let hi = f32x4_splat(1.0)
let clamped = f32x4_min(f32x4_max(v, lo), hi) // via min/max
// or, equivalently, via a mask + select:
let tooBig = f32x4_gt(v, hi) // → lane mask
let out = f32x4_select(tooBig, hi, clamped) // hi where v>hi, else clamped
Lengths must be multiples of 4 for SIMD loops. Callers pad as needed.
Memory discipline
JS allocates every byte; wasm functions never allocate.
This is the load-bearing invariant of the whole system. Wasm functions receive
typed-array pointers and operate on memory regions reachable from arguments.
The JS-side wrapper (or the caller, for wasmBuffer-backed arrays) is
responsible for allocation and lifetime.
wasmBuffer()
Allocate a typed array backed by wasm linear memory:
const xs = wasmBuffer(Float32Array, 50000)
xs[0] = 1.0 // writes directly into wasm memory
dot(xs, ys, 50000) // zero-copy: wrapper detects the shared buffer
Bump allocator caveat
wasmBuffer uses a bump allocator with no free — allocations persist for
program lifetime. This is deliberate: it eliminates lifetime/ownership
questions across library boundaries. The cost: long-running programs that
allocate per-call would leak.
The stdlib API shape (Phase 5+) follows from this: every function that would
produce a buffer takes an out parameter instead, so the caller allocates
once and reuses. Functions returning scalars (dot, norm_sq) are fine
as-is.
What this eliminates
- Cross-block allocator coordination
- Lifetime / ownership across library boundaries
- Fragmentation
- Inline-vs-boundary semantic divergence
Cross-file composition (Phase 3)
A file can declare wasm functions; another file can import and use them.
The import is resolved at transpile time and the imported function's body
is composed into the consumer's wasm module as a local function — no
JS↔wasm boundary on intra-module calls.
Library file
// my-lib/index.tjs
export wasm function dot(a: Float32Array, b: Float32Array, n: i32): f64 {
// ... SIMD dot product ...
}
Consumer file
// app.tjs
import { dot } from './my-lib/index.tjs'
const a = wasmBuffer(Float32Array, 128)
const b = wasmBuffer(Float32Array, 128)
// ... populate ...
const d = dot(a, b, 128) // calls into the composed wasm module
The consumer's transpiled output contains the wasm bytes for dot as a local
function in its single WebAssembly.Module. No separate library bundle is
fetched at runtime.
Enabling composition
The composition pass is opt-in: pass a ModuleLoader to tjs() or
transpileToJS(). Without one, imports are preserved verbatim and the
runtime resolves them as before.
import { tjs, ModuleLoader } from 'tjs-lang/lang'
const loader = new ModuleLoader({ baseDir: '/path/to/project' })
const result = tjs(appSource, {
moduleLoader: loader,
filename: '/path/to/project/app.tjs',
})
Two distribution forms
Same source produces two outputs depending on how the library is consumed:
Composed form (tjs-to-tjs, when the consumer transpiles with a loader):
- Library's wasm functions become local exports in the consumer's module
- One
WebAssembly.compileper consumer file - Intra-module calls between the consumer's own wasm and the imported wasm are pure wasm calls (no JS↔wasm boundary)
Boundary form (when the library is consumed as a published .js):
- Library transpiles to a self-contained
.jswith embedded wasm + JS wrappers - Consumer imports the
.jsvia normal ESM - Each call from JS into a library wrapper crosses the JS↔wasm boundary
- Per-call cost is single-digit nanoseconds for small functions — usually negligible after JIT warmup
Both forms compile from the same TJS source and produce identical results.
A published library typically ships both:
my-lib/
src/
index.tjs ← TJS source, used by tjs consumers (composed form)
dist/
index.js ← Transpiled, used by everyone else (boundary form)
package.json exports:
"exports": {
"./my-lib": {
"bun": "./src/index.tjs",
"default": "./dist/index.js"
}
}
Purity and the (!) unsafe marker
A wasm function may not call any host function. This rule is enforced by
the backend: the bytecode builder has no support for emitting host imports,
so any attempt (e.g. Math.sin, which would require a JS-side trampoline)
fails at compile time. Math functions with wasm intrinsics — sqrt, abs,
floor, ceil, min, max — work fine.
The (!) marker is reserved syntax for a future "unsafe wasm function"
variant that would relax purity (allow host imports, side-effecting globals).
Writing wasm function (! name(...) today produces a clear error directing
you to either remove the bang or wait for the unsafe variant to be implemented.
tjs-lang/linalg
The first stdlib library built on this infrastructure ships two SIMD kernels:
import { dot, norm_sq } from 'tjs-lang/linalg'
const a = wasmBuffer(Float32Array, 128)
const b = wasmBuffer(Float32Array, 128)
// ... populate ...
const cos = dot(a, b, 128) / Math.sqrt(norm_sq(a, 128) * norm_sq(b, 128))
Preconditions: n must be a multiple of 4 (the SIMD lane width). Pad
shorter vectors with zeros.
Future additions (planned, not yet shipped): norm, normalize,
add, sub, scale, lerp, matmul, transpose, cross, quat_mul,
look_at, perspective. See wasm-library-plan.md § 5.
Supported subset inside wasm{} / wasm function
A wasm{} body is a small, typed subset of JS, not arbitrary code. Anything
outside it is a compile error naming the construct: a wasm{} block that cannot compile
stops the transpile (since 0.14). fallback{} is only for a runtime that cannot run WASM (see
§ Runtime). Supported:
- Numeric locals:
let x = <expr>(i32 or f64, inferred from the initializer). forloops with numeric bounds —for (let i = 0; i < n; i += k) { … }— including nested loops.break/continuework.if/elseand the logical operators&&/||.- Arithmetic / comparison on i32 and f64 (see the coercion gotcha below).
- Typed-array element access —
arr[i]reads/writes, wherearris a typed- array parameter (annotatedFloat32Array/Float64Array/Int32Array/Uint8Array, marshaled to a pointer). A plain[0.0]example-typed param is a JS array, not a wasm pointer — annotate it asFloat32Array. - Math intrinsics:
sqrt,abs,floor,ceil,trunc,min,max(each a single WASM instruction).Math.sin,cos,log,exp,powand the rest have no WASM instruction and would need a call out to JavaScript, so a block using one does not compile (see § Purity). - SIMD: the full
f32x4_*set (see § SIMD intrinsics).
Not supported (a compile error naming the construct): allocation (JS owns all memory), function
calls other than the intrinsics above, closures, objects/strings, try/catch,
while (use for).
Numbers: what is f64 and what is i32
Inline blocks are JavaScript-exact. Their parameters are f64 whatever the TJS
annotation (0, 0.0, int): f64 holds every integer JavaScript represents exactly and
has JavaScript's arithmetic, so the block and its fallback {} give the same answer. A 0
annotation is checked at the call boundary (unless !); nothing converts or rounds the value.
Inside a block, let i = 0 is an i32, which is what loop counters and offsets want.
== and != are JavaScript's inside a block, not TJS's: x != x is true for NaN, as
WebAssembly's f64.ne says, and the block's implicit fallback compares the same way. An
explicit fallback {} is ordinary TJS.
/ is JavaScript's division everywhere, even with two i32 operands: 7 / 2 is 3.5.
For integer division write Math.trunc(a / b). (Until 0.14, i32 / i32 truncated and a lint
warned about it.)
% and the bitwise operators (|, &, ^, <<, >>, >>>) need i32 operands. WASM
has no float remainder or bitwise instructions, so with f64 operands the block does not
compile; the error names the operator.
For 32-bit integer algorithms (hashes, checksums, xorshift random numbers, packed RGBA),
declare i32 parameters on a wasm function. Their semantics are WebAssembly's, not
JavaScript's, which is the point:
i32 in a wasm function |
JavaScript | |
|---|---|---|
| A fractional argument | truncated at the call: f(2.5) sees 2 |
2.5 |
| An argument beyond 32 bits | wraps modulo 2³²: 3e9 arrives as -1294967296 |
3e9 |
+, -, * |
wrap at 32 bits: 2e9 + 2e9 is -294967296 |
4000000000 |
/ |
JavaScript division: 7 / 2 is 3.5 |
3.5 |
% |
sign of the dividend: -7 % 2 is -1 |
-1 |
% 0 |
throws (WebAssembly traps) | NaN |
| The return value | widened to f64: an ordinary number |
These rows are measured, and pinned by src/lang/wasm-semantics.test.ts.
Current limitations (v1)
These are real constraints worth knowing about. Most are scoped follow-ups from the wasm-library plan.
- Return types:
f64and void only.i32,f32,v128returns are parsed but not yet emitted as the bytecode return type. - SIMD lengths: SIMD kernels assume
n % 4 == 0. No tail-loop yet. - Bump allocator:
wasmBufferallocations persist forever. Useoutparameters for repeated allocations; design APIs around buffer reuse. - Unsafe marker reserved:
(!)syntax is parsed but rejected pending the unsafe variant implementation (Phase 7). - One file = one module: the consumer's transpiled output has exactly one
WebAssembly.Module, period. Cross-module wasm-to-wasm calls aren't a thing — they're composed at the source level into the same module. - Boundary form per-call cost: when a library is consumed via its
transpiled
.js, each call crosses the JS↔wasm boundary. Engines inline small functions at JIT time, but the boundary still costs single-digit nanoseconds. Composed form (tjs-to-tjs) avoids this. - Library duplication across
.jsdeps: if two third-party libraries both embedlinalgin their.jsdistribution, the consumer carries two copies. Matches JS ecosystem norms — a future "shared wasm module registry" could dedup at the cost of new infrastructure.
References
docs/WASM-QUICKSTART.md— 5-minute introduction (inline blocks)wasm-library-plan.md— Design rationale and phased implementation planTJS-SYNTAX.md§ "WASM Blocks" — Inline block referencesrc/linalg/index.tjs— First stdlib library, readable as source