I never really understood assembly, but I’ve always been fascinated by how little abstraction sits between you and the machine. Eventually 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 outputs assembly directly? I wasn’t sure it was even a good idea. The result is 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, allocate registers, run optimization passes, and eventually emit instructions. That’s a lot of machinery for a problem I didn’t have, since I wasn’t trying to design a new programming language. I wanted a nice way to write assembly, the way you’d use NASM’s %macro or MASM’s directives, but with a real language behind the macros in place of a tiny text substitution system.
TypeScript already has functions, closures, loops, and async/await. If mov(rax, 10) is a function call that records a mov instruction instead of executing anything, then an ordinary TypeScript function that calls a few of these is already a macro. You don’t need new syntax, a parser, or an intermediate representation. jasm doesn’t do register allocation because it has no variables: you write rax and get %rax. All the abstraction is in which functions you call; nothing gets compiled into something else.
So the core is small. Instructions are functions that push nodes onto a list, and at the end the list is turned into text. The rest of the project follows from that.
What it 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 is a plain function that happens to call mov and add. Call it twice and you get the instructions twice; put it in a loop and it’s unrolled. Macro expansion is just running the TypeScript.
The await in the middle is my favourite part. Config lookups, fetches, file reads, anything you’d want at build time can happen inline with real async/await, because generating the program means running a Bun script. The output, though, has 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 instead of silently stringifying it into the output. Async is a convenience at generation time, and the emitted program never knows it happened.
Targets, syntax, and admitting the differences
jasm targets x86-64 and arm64 on Linux (ELF) and macOS (Mach-O), in gas syntax, plus nasm for x86 on ELF. Those four combinations disagree about more than I expected: section names, syscall numbers, calling conventions, the layout of a sockaddr_in, whether the local-label prefix is .L or L, and whether an error comes back as a negative return value or a set carry flag.
The temptation with something like this is to cover the differences with one unified API and hope it holds. I tried not to. Where the machines differ, jasm says so. A syscall with no equivalent on the target platform is a build-time error that names what’s missing, so you never get a plausible-looking wrong number. Registers are typed by architecture, so an arm64 register in an x86_64 build fails immediately with a message naming the register and the two architectures, instead of producing text that assembles into garbage.
Symbol addresses are always PC-relative (lea msg(%rip) on x86, adrp/add :lo12: on arm64). It’s a small detail, but it means the output links as a PIE on every target without extra flags or any thought on your part.
Plugins, and why there’s an HTTP server
Plugins hook into three points: setup (before generation), beforeEmit (the last chance to record), and transform (rewriting the final text). If you want to see whether the idea scales past toy examples, look at the HTTP server plugin. 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 into .rodata. It runs on all four target combinations. None of it depends on Bun at runtime; Bun only 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 to real registers doing real work (%r12/%r13/%r14 on x86-64, x19 to x22 on arm64). Route matching is a set of compares against fixed-length literals. The routes are known at generation time, so there’s no reason to build a hash map for something TypeScript already knows.
There’s also a JSON plugin, which bakes constants at generation time and assembles documents from live registers at runtime. And there’s a macOS UI plugin that drives AppKit through the Objective-C runtime directly: objc_msgSend calls, pooled NSRect constants, and a hand-rolled line-number gutter using an Objective-C class defined at runtime with objc_allocateClassPair. That one exists mostly because I wanted to see how far “function calls that emit instructions” would stretch before it broke. It didn’t break. It just got weirder.
A standard library on top
Once the core worked, I wanted to know whether the raw instruction API was expressive enough to build real control flow (if, while, for, structs, stack frames) without touching the assembler. That became jasm/std. I held it to a strict rule: every helper has to reduce to what a person would write by hand, and nothing from the library may 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 architecture-specific in the standard library lives in one interface, StdTarget, with two implementations. It has about forty methods, and each is one or two real instructions. Every loop, stack frame, and 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. The first is that nothing unnecessary gets emitted. add(rax, 0) produces no instruction. An if without an else has no jump over a missing branch. A loop nobody breaks out of has no exit label. A leaf function with no locals gets no stack frame; the prologue only appears once something needs it.
The second is that where there’s no portable answer, jasm says so instead of faking one. Waiting on two sockets at once means epoll on Linux, kqueue on macOS, and a run loop under AppKit. Those are different programs, not three spellings of one operation. So race() without a scheduler that can actually do it fails at generation time and names the missing capability. It won’t quietly emit a spin loop that passes for an event loop until it doesn’t.
I’m most pleased with how I test the claim that std helpers emit what you’d write by hand, because that kind of claim is easy to make and easy to break without noticing after a few refactors. The test suite generates the same routine twice, once with the std helper and once by calling the raw backend instructions directly, and asserts that the two outputs are identical character for character, labels included. If a helper starts emitting an extra jmp, leaves an unreferenced label behind, or adds a redundant mov, the test fails. The claim is checked mechanically instead of living in a comment.
Why not 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 a restricted subset of some language, and a compiler pass turns it into instructions according to rules you have to learn. I didn’t want the subset or the pass. I wanted to write the actual instructions myself, with TypeScript’s control flow and module system doing the organizing, because I already know those tools well and didn’t want to relearn them in miniature inside someone else’s DSL.
The tradeoff is real. jasm doesn’t optimize, doesn’t allocate registers, and doesn’t check that your assembly is correct beyond the structural mistakes it can catch (wrong architecture, malformed operands, a stray Promise). You’re still writing assembly, just with functions, loops, types, and await in place of a text preprocessor from the 1980s. And what comes out is exactly the instructions you asked for, with nothing hidden and nothing left behind.
bun install
bun test
bun run examples/hello.ts
It’s open source and runs on Bun. If you’ve ever wanted to poke at what’s happening a few layers below your usual code, I think it’s a fun way in.