Skip to content

aowlts — idiomatic TypeScript backend

An idiomatic TypeScript backend for nimony: Nim types become real TypeScript types, not byte offsets.

Status: working core. The emitter transpiles the computational core of the language end-to-end and runs byte-identically to native nimony on the test suite. Advanced features (closures, generic instantiation edge cases, macros) are the remaining work.

Different from aowlweb

aowlweb already takes Nim to the web, but over a linear-memory model — one ArrayBuffer, pointers are integers, objects are byte offsets (HEAP32[p>>2]). Fast and faithful, but the output is asm.js-style, not something a TypeScript developer would read or type-check. aowlts is the opposite trade-off: idiomatic, readable, fully-typed TS.

aowlweb (nim-js)aowlts
memoryone ArrayBuffer, offsetsreal JS objects, engine GC
objectbytes at a byte offsetinterface / class
seq[T]header+data in the bufferT[]
stringlinear bytesstring
outputfast, faithful, asm-stylereadable, typed, idiomatic

They're separate projects on purpose — the one asset that unifies aowlweb (the jslayout byte-layout engine) is exactly what an idiomatic backend throws away.

Planned architecture

The idiomatic mapping consumes nimony's sem'd, pre-hexer NIF — the .s.nif artifact nimony already writes unconditionally — which is dramatically more idiomatic than the post-hexer .c.nif that aowlweb consumes:

construct.s.nif (consumed here).c.nif (aowlweb)
echo "x"(cmd write stdout "x") — literal intactLongString refcount struct + SSO
objectnominal, named fieldsbytes at a byte offset
seq[T]generic (at seq T)header+data+cap struct
try/raisestructured TryS/RaiseSerror-code returns + goto

The Nim-semantic lowering is shared with aowlpy via aowlhl; aowlts is the thin emitter that renders the High-Level IR into TypeScript.

Architecture

aowlts mirrors the JavaScript emitter aowljs node-for-node — same HL-IR walk, same call-site intercepts — and adds a TypeScript type layer on top. It consumes the three aowlhl modules:

  • hlwalk — the grammar shape decoders (decodeLocal / decodeParam(s) / decodeProc / decodeIf / decodeCase) so the walk skeleton is shared, not re-implemented;
  • hlclassify — routine-pragma classification (hasImportc) to skip foreign declarations;
  • hlload — the user-module import graph + moduleInitOrder, so imported user modules are emitted in dependency-first order (their top-level init runs before the main module, matching native module-initialization order).

Two source files, mirroring the aowljs split:

  • src/emitts.nim — the emitter (type mapping + node emission);
  • src/aowlts_cli.nim — the ~60-line driver: load the main .s.nif, walk the import graph, concatenate prelude + imported modules + main + flush.

Each nimony symbol is mangled to a stable TS identifier, and a symbol is emitted only in the pass for its owning module — so std/system generic-instance hooks (the seq runtime, =destroy/=copy, …) are intercepted at call sites and never leak into the output.

Type mapping

nimonyTypeScript
int, int8uint64, float, bytenumber
string, cstring, charstring
boolboolean
seq[T], array[N, T], openArray[T]T[]
objectinterface { … }
enumenum { … } (members qualified E.Member)
tuple[a: X, b: Y][X, Y]
ref T / ptr Tthe underlying object type
var/out paramboxed accessor {v: T} (pass-by-reference)

let/const are chosen from the binding's mutability (varlet, let/ constconst).

In faithful mode (below) the width-64 integer types int / int64 / uint / uint64 map to bigint instead of number; the narrower widths (int8int32, uint8uint32, byte, char) stay number.

Faithfulness / export modes

aowlts has two output modes. The default is fast mode; --faithful opts into a numerically exact one. Fast mode stays the default because its output is the most idiomatic and the fastest — most programs never touch the boundary where it diverges.

fast mode (default)--faithful
every nimony intJS number64-bit → bigint, narrower → number
exact past 2^53no (silently rounds)yes
int64 / uint64 overflowwrong (no wrap)wraps (two's complement)
speed / readabilityfastest, cleanesta touch heavier (bigint ops)

Why fast mode is silently wrong

JS number is an IEEE-754 double: it holds integers exactly only up to 2^53. A nimony int64 past that rounds, and int64/uint64 arithmetic never wraps. Compile this with nimony and with each mode:

nim
var a: int64 = 9223372036854775807'i64   # INT64_MAX
echo a
echo a + 1'i64                            # wraps to INT64_MIN
var u: uint64 = 18446744073709551615'u64  # UINT64_MAX
echo u
linenimony (reference)--faithfulfast mode
a922337203685477580792233720368547758079223372036854776000
a + 1-9223372036854775808-92233720368547758089223372036854776000
u184467440737095516151844674407370955161518446744073709552000

Faithful mode is byte-for-byte identical to native nimony; fast mode is off by hundreds and never wraps.

The design: native bigint, zero dependencies

JS has had BigInt as a language primitive since ES2020 — it works in Node, Deno and the browser with no addon and no import. Its operators are the ordinary ones (a + b, a < b, a === b), and its / and % truncate toward zero exactly like Nim integer division. Faithful mode leans on that:

  • 64-bit types → bigint. A bigint literal carries the n suffix (123n); the emitter threads a "want-bigint" context so every literal in a 64-bit position is emitted as bigint (JS throws on mixing 5n + 5).
  • Width wrapping. 64-bit add/sub/mul/shl and the bitwise ops are wrapped with the built-in BigInt.asIntN(64, x) (signed) / asUintN(64, x) (unsigned) — that is exactly Nim's two's-complement wrap-around.
  • Division. div_idiv(a, b), mod_imod(a, b) — bigint / and % already truncate toward zero, so the helpers only add the Nim DivByZero check.
  • Crossing widths. A number value entering a 64-bit position is coerced BigInt(x) (a float first BigInt(Math.trunc(x))); a bigint used where a number is needed — array indices, narrower ints — is coerced Number(x).
  • echo. A bigint prints through String(x), i.e. 5n renders 5, not 5n — output matches nimony with no special-casing.

Runtime helpers

Faithful mode needs four tiny helpers. They are inlined into the emitted program's prelude, so a single emitted .ts file still runs standalone with no import:

ts
const _i64  = (x: bigint): bigint => BigInt.asIntN(64, x);
const _u64  = (x: bigint): bigint => BigInt.asUintN(64, x);
const _idiv = (a: bigint, b: bigint): bigint => { if (b === 0n) throw new Error("DivByZero"); return a / b; };
const _imod = (a: bigint, b: bigint): bigint => { if (b === 0n) throw new Error("DivByZero"); return a % b; };

The same four are also exported from runtime/aowl-rt.ts for real projects that prefer to import one shared copy rather than rely on the inlined prelude.

Invoking it

sh
bin/aowlts --faithful /tmp/nc/<mainhash>.s.nif > prog.ts
node --experimental-transform-types prog.ts

tests/run_faithful.sh compiles each tests/faithful/*.nim with nimony for the reference stdout, transpiles it with --faithful, runs the emitted .ts under node, and diffs — byte-exact — while also showing that fast mode gets the same programs wrong (the whole point). Suite: overflow (INT64_MAX / MIN, UINT64_MAX), modmul (products past 2^53 in a modular loop), divmod (truncating div/mod, signed and unsigned).

Boundary — what faithful mode does not fix

Faithful mode is about numeric faithfulness. Two other faithfulness axes are deliberately out of scope:

  • Value semantics. Nim copies object/tuple/seq values on assignment and argument passing; the idiomatic TS emitter shares references (like the fast mode). Deep-copy-on-assign is a separate, future axis of faithfulness.
  • Raw pointers, addr, cast, manual memory, pointer arithmetic. These have no honest idiomatic-JS representation. If a program depends on them, use the machine-faithful aowlweb backend, whose linear-memory model is the faithful answer for pointer-level code.

One honest numeric edge: faithful mode implements wrapping two's-complement arithmetic (_i64/_u64 around every 64-bit result), not Nim's checked integer defects. So negating INT64_MIN, or an add that overflows, wraps silently instead of raising an OverflowDefect. In practice this matches what nimony emits with runtime overflow checks off (its default here — -INT64_MIN prints -9223372036854775808 under both, byte-exact); a build that raises overflow defects is a possible future refinement, but wrapping is the same trade every wrapping backend makes and is what hardware does.

What's covered

  • literals: int / uint / float / char / string / bool;
  • arithmetic, bitwise, comparison and boolean operators (with 32-bit wrap via Math.imul / | 0 where the type is int32/uint32);
  • div/mod, integer vs. float division;
  • variables (let/const from mutability, typed), assignment;
  • procs → typed functions (typed params & return), result variable, return;
  • control flow: if/elif/else, while, case (as statement and as expression via an IIFE), for over ranges / countdown / collections, with one or two loop variables; break / continue;
  • objectinterface, object construction → object literal, field access;
  • ref object → object reference (construct/field-read/mutate, == nil=== null); ARC/RTTI hooks are dropped (JS is GC'd);
  • inheritance (object of) → interface … extends …, base fields flattened; upcast is identity (structural);
  • custom iterators → native generators (function* + yield, driven by for..of);
  • closures → inline arrow functions with native lexical capture;
  • enum → real TS enum, qualified member references;
  • seq/array literals, indexing, index-store (s[i] = v), len, add, newSeq(n);
  • string ops (&, len, add, $, relational ==/</>/…, slicing s[a..b]/s[a..<b], case/trim/split/contains helpers);
  • user procs whose names collide with builtins (add, len, inc, ord, …) emit real calls — magic dispatch is gated on symbol origin, not name;
  • user generic instances (monomorphized) are emitted;
  • tuples (construction, indexing, tuple return types);
  • var/out parameters via boxing;
  • float values print with a trailing .0 via echo/$ (tracked through a static float-type environment, incl. tuple float elements);
  • echo (→ a captured-output shim printed once at the end);
  • multi-module programs (imported user modules emitted in dependency order).

Build & run

sh
NIM=/path/to/nimony            # a nimony checkout (v0.4.0)
$NIM/bin/nimony c -o:bin/aowlts \
  -p:$NIM/src/lib -p:$NIM/src/nimony -p:$NIM/src/models -p:$NIM/src/gear2 \
  -p:../aowlhl/src -p:src \
  src/aowlts_cli.nim

# produce a program's typed NIF (also runs it natively), then transpile:
$NIM/bin/nimony c -r --nimcache:/tmp/nc -f prog.nim
bin/aowlts /tmp/nc/<mainhash>.s.nif > prog.ts

# the emitted TS runs on node >= 23 (enums need --experimental-transform-types),
# or type-check with tsc / run with deno:
node --experimental-transform-types prog.ts

The test harness (tests/run.sh) compiles each tests/*.nim with nimony for the reference stdout, transpiles it, runs the emitted .ts with node, and diffs. Current suite: 18/18 byte-identical fast + 9/9 faithful. The shared differential corpus (aowlhl/corpus, 54 programs vs native nimony) sits at 51/54 fast, 54/54 faithful (faithful is a clean sweep); the remaining fast-mode fails are the by-design int64 cases below.

Also lowered to native TS: exceptions (try/except T as e/raisethrow/try/catch, with Exception-derived types emitted as class … extends Error so instanceof works), defer (→ try/finally), variant/case objects, distinct types, set algebra (in/+/-/*/^/card), and seq higher-order funcs (filter/map with an anonymous proc → native Array.filter/.map + arrow), and HashSet → native Set (incl/excl/ contains/in/len.size).

Known limitations / TODO

  • enums compile to real TS enums, which are not erasable — plain node --experimental-strip-types rejects them; use --experimental-transform-types, tsc, or deno;
  • fast-mode int64/uint64 wrap/precision past 2⁵³ — by design; use --faithful (the corpus's only fast-mode fails are these);
  • set[T] membership emits an inline Set/OR-chain (functional, not typed as a nominal set);
  • multiple sibling except clauses on one try (nimony's single-except + instanceof-cascade lowering is handled; truly separate clauses are untested);
  • macros / compile-time execution are future work.

aoughwl — self-hosted platform for things n stuff. Contact / Support on Discord for access to the private backends.