I never really understood assembly. But I’ve always been fascinated by how low-level it is — barely any abstraction between you and the machine. At some point that turned into a question: instead of building a whole language like C on top of it, could I get away with a macro system in TypeScript that just outputs assembly directly? I wasn’t sure it was even a good idea. But here goes — jasm, a TypeScript macro assembler that emits clean x86-64 and arm64 assembly for Linux and macOS, from code that looks like ordinary TypeScript.
The idea
Most things that generate assembly are compilers. They parse a language, build an AST, do register allocation, run optimization passes, and eventually spit out instructions. That’s a lot of machinery, and all of it exists to solve a problem I didn’t actually have: I wasn’t trying to design a new programming language. I just wanted a nice way to write assembly, the way you’d reach for a macro assembler like NASM’s %macro or MASM’s directives — except with a real language behind the macros instead of a tiny textual substitution system.
TypeScript already has functions, closures, loops, and async/await. If mov(rax, 10) is just a function call that records “here’s a mov instruction” instead of executing anything, then an ordinary TypeScript function that calls a few of these is already a macro. No new syntax, no parser, no intermediate representation to design. Register allocation isn’t a problem jasm solves, because jasm doesn’t have variables — you write rax, you get %rax. The abstraction is entirely at the level of “which functions do I call,” never “what does this line of code compile to.”
So the core idea is small: instructions are functions that push nodes onto a list, and the list gets turned into text at the end. Everything else in the project follows from that.
What it actually looks like
import { createAssembler, x86 } from "jasm";
const { rax, rdi, rbp, rsp } = x86.regs;
const { mov, add, push, pop, ret, syscall, xor } = x86.ops;
const program = await createAssembler({ arch: "x86_64" }).build(async (asm) => {
function add3(dest: typeof rax, a: number, b: number, c: number) {
mov(dest, a);
add(dest, b);
add(dest, c);
}
asm.section("text");
asm.global("_start");
asm.label("_start");
push(rbp);
mov(rbp, rsp);
add3(rax, 10, 20, 30);
mov(rdi, rax);
const { multiplier } = await Bun.file("./config.json").json();
add(rdi, multiplier);
mov(rax, 60);
xor(rdi, rdi);
syscall();
});
console.log(program.source); // pure assembly, nothing else
add3 isn’t a macro in any special sense — it’s a plain function that happens to call mov and add. Call it twice, get the instructions twice. Put it in a loop, get it unrolled. There’s no macro-expansion pass separate from “just running the TypeScript.”
The await in the middle is the part I like most. Config lookups, fetches, file reads — anything you’d normally want at build time — can happen inline, using real async/await, because generation is just running a Bun script. But the artifact that comes out the other end is required to be pure: no Promise, no scheduler, no leftover async machinery. If you forget an await and pass a Promise somewhere it doesn’t belong, jasm throws immediately rather than silently stringifying it into the output. Async is a generation-time convenience, and the emitted program is not allowed to know it happened.
Targets, syntax, and not lying about differences
jasm targets x86-64 and arm64, on Linux (ELF) and macOS (Mach-O), in gas syntax (nasm too, for x86/ELF). That’s four real combinations of “how does this actually get assembled,” and they disagree about more than I expected going in: section names, syscall numbers, calling conventions, how a sockaddr_in is laid out, whether the local-label prefix is .L or L, whether an error comes back as a negative return value or a set carry flag.
The instinct when building something like this is to paper over those differences with one unified API and hope it holds. I tried to avoid that. Where the machines actually differ, jasm says so instead of pretending — a syscall with no equivalent on the target platform is a build-time error naming exactly what’s missing, not a plausible-looking wrong number. Registers are typed by architecture, so an arm64 register reaching an x86_64 build fails immediately with a message telling you which register and which two architectures disagree, instead of producing text that silently assembles into garbage.
Symbol addresses are always taken PC-relative — lea msg(%rip) on x86, adrp/add :lo12: on arm64 — which is a small thing but it means the output links as a PIE without extra flags, on every target, without you thinking about it.
Plugins, and why the HTTP server one exists
Plugins hook into three points: setup (before generation), beforeEmit (last chance to record), and transform (rewrite the final text). The HTTP server plugin is the one I’d point to if you want to see whether the idea actually scales past toy examples — it emits a complete, standalone HTTP/1.1 server: socket, bind, listen, an accept loop, a fixed-length compare chain for routing, and static response bodies baked straight into .rodata. It runs on all four target combinations. Nothing about it lives inside Bun at runtime; Bun’s only job is to have generated the text.
await asm.http.serve({ port: 3847 }, (route) => {
route.get("/health", { status: 200, body: "ok" });
route.notFound({ status: 404, body: "not found\n" });
});
That compiles down to real registers doing real work — %r12/%r13/%r14 on x86-64, x19–x22 on arm64 — and route matching is just compares against fixed-length literals, because the routes are known at generation time and there’s no reason to build a hash map for something TypeScript already knows the answer to.
There’s also a JSON plugin (bakes constants at generation time, assembles documents from live registers at runtime) and a macOS UI plugin that drives AppKit through the Objective-C runtime directly — objc_msgSend calls, pooled NSRect constants, a hand-rolled line-number gutter with a runtime-defined Objective-C class built via objc_allocateClassPair. That one exists mostly because I wanted to see how far “it’s just function calls that emit instructions” would stretch before it broke. It didn’t break; it got weirder.
Building a standard library on top of it
Once the core worked, the obvious next question was whether the primitive instruction API was actually expressive enough to build real control flow on top of it — if, while, for, structs, stack frames — without touching the assembler itself. That became jasm/std, and the constraint I held it to was strict: every helper has to reduce to what a person would write by hand, and nothing from the library is allowed to survive into the output.
defineFunction("sum_to", (fn) => {
const total = fn.frame.local(u64);
mov(total, 0);
forRange(fn.arg(1), 0, fn.arg(0), (i) => add(total, i));
mov(fn.result, total);
});
That single source generates this on x86-64:
sum_to:
push %rbp
mov %rsp, %rbp
sub $16, %rsp
movq $0, -8(%rbp)
mov $0, %rsi
.Lsum_to_for_top_1:
cmp %rdi, %rsi
jge .Lsum_to_for_end_3
add %rsi, -8(%rbp)
inc %rsi
jmp .Lsum_to_for_top_1
.Lsum_to_for_end_3:
mov -8(%rbp), %rax
leave
ret
and this on arm64, from the exact same call:
sum_to:
sub sp, sp, #32
stp x29, x30, [sp, #16]
add x29, sp, #16
str xzr, [sp]
mov x1, #0
.Lsum_to_for_top_1:
cmp x1, x0
b.ge .Lsum_to_for_end_3
ldr x9, [sp]
add x9, x9, x1
str x9, [sp]
add x1, x1, #1
b .Lsum_to_for_top_1
.Lsum_to_for_end_3:
ldr x0, [sp]
ldp x29, x30, [sp, #16]
add sp, sp, #32
ret
Everything arch-specific in the entire standard library lives in one interface, StdTarget, with two implementations behind it — about forty methods, each of which is one or two real instructions. Every loop, every stack frame, every string routine in jasm/std is written once, against that interface, and works on both machines. Nothing else in the library knows which architecture it’s targeting.
Two rules kept it honest. First: nothing unnecessary gets emitted. add(rax, 0) produces no instruction. An if with no else has no jump over an else it doesn’t have. A loop nobody breaks out of has no exit label. A leaf function with no locals gets no stack frame at all — the prologue only shows up once something actually needs it. Second: where there’s genuinely no portable answer, say so instead of faking one. Waiting on two sockets at once is epoll on Linux, kqueue on macOS, a run loop under AppKit — those aren’t three spellings of the same operation, they’re different programs. So race() without a scheduler that can actually do that fails at generation time, naming the missing capability, instead of quietly emitting a spin loop that looks like an event loop until it isn’t one.
The part I’m most satisfied with is how I ended up testing the claim that std helpers emit “what you’d write by hand” — because that’s the kind of claim that’s easy to state and easy to silently stop being true after a few refactors. The test suite generates the same routine twice: once with the std helper, once by calling the raw backend instructions directly. Then it asserts the two output strings are character-for-character identical, labels included. If a helper starts emitting one extra jmp, or leaves an unreferenced label lying around, or does a redundant mov, that test fails immediately. It’s a strong claim, but it’s checked mechanically rather than just asserted in a comment.
Why this, instead of an existing tool
There are plenty of ways to emit assembly from higher-level code, and most of them are compilers in disguise — you write in some restricted subset of a language, and a pass turns it into instructions with rules you have to learn. I didn’t want the restricted subset, and I didn’t want the pass. I wanted the actual instructions, written by me, with TypeScript’s existing control flow and module system doing the organizing — because those are tools I already understand deeply, and I didn’t want to relearn them in miniature inside someone else’s DSL.
The tradeoff is real: jasm doesn’t optimize anything, doesn’t allocate registers for you, doesn’t check that your assembly is correct beyond typos it can catch structurally (wrong architecture, malformed operands, a stray Promise). You’re still writing assembly. What changes is that you’re writing it with functions, loops, types, and await, instead of a text preprocessor from the 1980s — and when you’re done, what comes out is exactly the instructions you asked for. Nothing hidden, nothing left behind, nothing pretending to be simpler than it is.
bun install
bun test
bun run examples/hello.ts
It’s open source, targets Bun, and if you’ve ever wanted to poke at what’s actually happening a few layers below your usual code, I think it’s a fun way in.