Compile TypeScript to machine code ahead of time: `bun build --compile --aot`

Merged
JA
11d ago
+2321195-25784
bun build --compile --aot ./app.ts
./app

This adds --aot to bun build --compile. It compiles TypeScript and JavaScript to machine code at build time. The executable starts as machine code and has no JIT in it. bun compile ./app.ts is short for the same command.

It's still JavaScript. npm packages, CommonJS, eval, proxies and any all work. There's no new syntax and nothing to annotate. The new part is that the compiler reads your TypeScript types and uses them.

class Vec {
  constructor(
    public x: number,
    public y: number,
  ) {}
}

function dot(a: Vec, b: Vec): number {
  return a.x * b.x + a.y * b.y;
}

A Vec is a 16-byte header and two raw doubles. Where the compiler can prove that only Vecs reach a.x, the read is one load. No hidden-class check, no inline cache, no boxed number. Where it can't prove that, it tests once and keeps a slow path.

It's experimental, and it loses in places. The engine half is oven-sh/WebKit#757. The docs start at docs/bundler/aot.mdx.

The TypeScript compiler, compiled

TypeScript 6 is written in TypeScript. TypeScript 7 is the TypeScript team's port of it to Go. We built the v6.0.3 tag from its own source with --aot and type checked effect with it, 370,839 lines. Not a line of TypeScript's source was changed.

wall, speak RSS, MBCPU, s
TypeScript 7 (Go)2.681,25610.8
TypeScript 6, --aot4.259635.3
TypeScript 7 (Go), --singleThreaded5.089336.2
TypeScript 6 on Bun 1.4.314.002,58129.2

tsc -p on effect's packages/effect, 0 errors in every row. Linux x64, AMD EPYC 9R14, pinned to 16 cores that nothing else uses. LTO build, median of five runs, 2026-10-24.

The two TypeScript 6 rows are the same compiler at the same tag. Compiled ahead of time it's 3.3x faster than on Bun 1.4.3, in 2.7x less memory and 5.5x less CPU time.

Go checks in parallel, which TypeScript 6 doesn't, and that wins on the clock. Take the parallelism away with --singleThreaded and the original TypeScript finishes first, on less CPU time and about the same memory. Both still collect garbage on other threads in that comparison.

How to run it yourself

Bun 1.4.3, TypeScript 7.0.2 (@typescript/typescript-linux-x64), TypeScript 6.0.3 (tag v6.0.3, sources and lib/ unmodified), effect at 8b0eac6a, @types/node 26.6.4.

git clone --depth 1 -b v6.0.3 https://github.com/microsoft/TypeScript
git clone https://github.com/Effect-TS/effect && git -C effect checkout 8b0eac6ac46a689a7208500e2ccf9e3b80fc3898
npm install typescript@7.0.2 @types/node@26.6.4

# --aot type checks what it compiles, and a bare checkout lacks two of TypeScript's
# dev dependencies (@types/node, source-map-support). This supplies them.
echo 'declare module "source-map-support";' > missing.d.ts
cat > aot-tsconfig.json <<X
{
  "extends": "$PWD/TypeScript/src/tsconfig-base.json",
  "compilerOptions": {
    "rootDir": "$PWD/TypeScript/src", "types": ["node"], "typeRoots": ["$PWD/node_modules/@types"],
    "composite": false, "declaration": false, "declarationMap": false, "emitDeclarationOnly": false,
    "isolatedDeclarations": false, "noEmit": true
  },
  "files": ["$PWD/TypeScript/src/tsc/tsc.ts", "./missing.d.ts"]
}
X
bun build --compile --aot --production --format=esm --tsconfig-override=aot-tsconfig.json \
  --define "__filename=\"$PWD/TypeScript/lib/tsc.js\"" --define "__dirname=\"$PWD/TypeScript/lib\"" \
  TypeScript/src/tsc/tsc.ts --outfile tsc-aot

go=node_modules/@typescript/typescript-linux-x64/lib/tsc
run="taskset -c 0-15 /usr/bin/time -v"
$run $go -p tsconfig.json --pretty false
$run ./tsc-aot -p tsconfig.json --pretty false
$run $go -p tsconfig.json --pretty false --singleThreaded
$run bun TypeScript/lib/tsc.js -p tsconfig.json --pretty false

tsconfig.json is effect's own options for packages/effect, with absolute paths:

{
  "include": ["<here>/effect/packages/effect/src"],
  "compilerOptions": {
    "noEmit": true, "incremental": false, "composite": false,
    "rootDir": "<here>/effect/packages/effect/src",
    "target": "ES2022", "module": "NodeNext", "moduleDetection": "force", "verbatimModuleSyntax": true, "allowJs": false,
    "rewriteRelativeImportExtensions": true, "erasableSyntaxOnly": true,
    "strict": true, "exactOptionalPropertyTypes": true, "noUnusedLocals": true, "noUnusedParameters": true,
    "noImplicitOverride": true, "noFallthroughCasesInSwitch": true,
    "stripInternal": false, "skipLibCheck": true, "noErrorTruncation": true, "jsx": "react-jsx",
    "types": ["node"], "typeRoots": ["<here>/node_modules/@types"], "preserveSymlinks": true
  }
}

A real CLI

Claude Code's CLI ships as a bun build --compile executable. This is the released build against the same app built with --aot, in a scripted session: start, first prompt, five turns, with the API stubbed out locally.

released--aot
time to first prompt, ms318141
peak private memory, MB185.444.7
private memory at the first prompt, MB112.028.7
private memory at the end, MB150.137.5
peak RSS, MB308.5180.8
CPU time, ms1,542512
instructions, millions5,5301,117

Linux x64, AMD EPYC 9R14, 16 cores that nothing else uses. LTO build, default flags, median of 16 sessions per column, alternating, 2026-10-24. CPU time and instructions count all threads.

The first prompt is up in under half the time, on a third of the CPU time and a quarter of the private memory.

Servers

One small API, written seven times (test/aot/servers/app/api-*.ts): by hand on Bun.serve and on node:http, and on Hono, Elysia, Express, Fastify and Koa. It has three middlewares (a request id, response headers, a bearer token), route parameters, a query string, JSON in and out, an error handler and a 404.

Each server is built twice by the same bun: with --aot, and with plain --compile, which runs it on the JIT. Same runtime, same built-in modules, same native code. Only the compiler differs.

Requests per second in the first second after the process starts:

--aotJITratio
Koa33,10917,1801.93x
Express46,56224,1811.93x
Fastify57,97637,8221.53x
node:http69,08546,6931.48x
Hono58,97848,0291.23x
Bun.serve84,59977,8671.09x
Elysia63,55661,5601.03x

Requests per second once the JIT is warm, over requests 100,001 to 400,000 of the same process:

--aotJITdifference
Hono59,73758,151+2.7%
Bun.serve85,55383,490+2.5%
node:http70,20570,383-0.3%
Express47,93551,587-7.1%
Elysia65,11075,268-13.5%
Fastify59,65671,796-16.9%
Koa34,19444,362-22.9%

The same program built twice differs by up to 1.9%, so we call anything under 3% level. That's the top three. The other four lose. Counting every request since the process started, the JIT has caught up within 2 seconds on Elysia and within 5 on Fastify and Koa. Express is still ahead at 5 seconds.

Startup and memory:

first response, ms, --aotfirst response, ms, JITpeak RSS, MB, --aotpeak RSS, MB, JIT
Bun.serve8.37.732.145.7
Hono9.311.534.451.8
Elysia11.527.540.856.6
node:http11.521.443.861.8
Fastify17.063.451.173.1
Express18.350.454.485.9
Koa20.166.757.285.3

All three tables: Linux x64, AMD EPYC 9R14, each server pinned to one core and its load generator to another, both columns of a row at the same time. LTO build, 64 connections, six routes in turn, every response compared byte for byte, median of 7 starts, 2026-10-24. Memory is read at request 400,000. test/aot/servers/measure/requests_a_second.py.

There's no source to parse, no bytecode, no JIT-compiled code and no profiling data to keep around. The machine code is file-backed pages of the executable, so the kernel shares it between processes. Private memory is 13 to 25 MB compiled and 15 to 47 MB on the JIT. The executables are 69 to 82 MB, against 141 to 144 MB.

At a fixed 5,000 requests per second, the 99th percentile of latency is 54 to 91 µs compiled and 77 to 477 µs on the JIT. The medians are 21 to 40 µs and 21 to 34 µs.

Where it loses

A warm JIT still wins on untyped npm code. That's the second servers table. Koa, Fastify and Express are untyped JavaScript in many small packages. A JIT learns which objects reach a property access by watching the code run. The compiler has to prove it at build time, and where it can't, the access is a lookup by name. Full types are the way out we're betting on.

Code that a package generates at runtime is interpreted, for as long as the program runs. zod 4, ajv, fast-json-stringify and Elysia generate their hot code with new Function. That's most of Elysia's loss above. For validation there's a way that needs no generated code: JSON.parse<T> and validate<T>.

Old, untyped JavaScript is slower than on the JIT: constructor functions, class helpers, asm.js output, arithmetic on whatever comes out of a plain array. A JIT watches a + b | 0, sees two int32s every time and compiles an add. The compiler has to prove that they're int32s, and where it can't, the + is a call to a generic add. JetStream2, the benchmark that JavaScriptCore's JIT is tuned on, is mostly code like this, and most of its tests are slower compiled.

Everyday idioms are mixed. test/aot/perf has 1,814 of them. Each is compiled twice, as written and with its types erased, and the slower of the two is compared with the same bun's warm JIT, by instructions.

aheadwithin 5%behindmedian ratio
each idiom as a small program9162236750.94
compiled as in a very large program4671181,2291.44

bun test/aot/perf/idioms-against-the-jit.ts, and again with --in-a-very-large-program. Linux x64, AMD EPYC 9R14, 2026-10-24. User-mode instructions per iteration, with startup and warm-up subtracted.

In the first row over a third are behind, 180 of them by more than 2x. And that row flatters the compiler. Each idiom is a program of its own, so its loop counts as hot and the idiom is inlined into it. A very large program can't have everything hot, and with no profile the compiler has to guess from loops and the call graph. The second row compiles the same idioms the way they'd be compiled there, and calls each through a function that's found at runtime, on the JIT too. Two thirds are behind, 687 by more than 2x.

Builds are slow. The build type checks the whole program and compiles every function that can be reached. There's no cache between builds, so a one-line change builds everything again. A large application takes tens of seconds and gigabytes of memory.

Executables are big. A small server is 69 MB. That's half of what --compile makes, and it's still 69 MB. --trim helps. bun itself grows by about 30 MB.

Go and Rust still win on small programs. A hello world in either starts sooner, holds a few megabytes and is a file of a few megabytes. Ours carries Bun's runtime.

Why

When a JavaScript tool gets too slow, the usual fix is to port it to Go or Rust. esbuild, SWC, Biome, oxc, Rolldown, Turbopack and now TypeScript itself went that way. It works, but it takes years, and afterwards most of the tool's users can't read or patch it.

JavaScript engines were designed for browsers. Code arrives as text from somewhere you don't trust, has to start right away, and can do anything. So the engine interprets it, watches which types show up, guesses, compiles the guess, and throws the compiled code away when the guess was wrong. All of that happens on the machine that runs the program, every time it starts.

A server or a CLI isn't in that position. You have all the source before you ship it. If it's TypeScript, you've also written down what everything is. Then the build erases that, and every process spends its first seconds working it out again.

--aot keeps the types and does the work once, at build time.

What it does with types

TypeScript is unsound on purpose. any, as, !, JSON.parse and .d.ts files all let you call something a number when it isn't. So the compiler reads a type in two ways.

For what your program makes, the type is a fact. new Vec(1, 2), or an object literal of a declared type, gets the layout of that type.

For what your program is given, the type is a prediction. An argument from a caller that the build can't see, or the result of JSON.parse, is tested once where it comes in. A value that fails the test takes a slow path.

function double(n: number): number {
  return n * 2;
}

const body: any = JSON.parse('{"visits":"3"}');
console.log(double(body.visits));
$ bun app.ts
6

$ bun build --compile --aot app.ts && ./app
6

double takes a raw number, so it can't take the string "3". A guard at its entry sees the string and sends the call to a second, slow version of double, which multiplies the way plain JavaScript does. The string stays a string. The next call with a number takes the fast version again.

The rules we hold the compiler to:

  1. A program that runs under bun run does the same thing compiled.
  2. Nothing throws at runtime because of a type. as, any and ! never convert a value and never throw.
  3. New errors show up at build time, with a message. The exceptions are listed under What changes.

JavaScript files are compiled the same way. A class has a fixed layout there too, and a flow analysis over the whole program follows objects from where they're created to where they're used, with or without annotations. Every property access ends up in one of three states:

A property access isWhenIt costs
freeThe compiler proved which objects get here: from the types, from following every object through the program, or from a test earlier in the functionOne load or store
testedA value that nobody checked can get inA shape test, then the load. If the test fails, the slow path
by nameNothing is knownA lookup at runtime, through a cache

The build prints the split, and --explain lists what's behind the slow ones, worst first, with a fix for each.

92.4% of property accesses are one load: 1,204 free, 61 tested, 38 by name (1,303)

An object with a fixed layout is still an object. Object.keys, JSON.stringify, spread and for...in see it like any other. You can add properties that the class didn't declare, delete the ones it did, and store a string in a number field through an any. That costs speed and nothing else.

JSON.parse<T>(text) (JSON from "bun") and validate<T>(value) (from "bun:bundle") check data against a type you've already declared, and make objects with the layout of T. These two do throw. That's what they're for.

import type { int32 } from "bun" is a 4-byte integer field. To TypeScript and under bun run it's a number. The build has to prove every store into one, and stops with an error where it can't.

What changes

Each of these is there because the compiler can't generate good code without it. docs/bundler/aot-compatibility.mdx has the smaller differences and a list of packages that are known not to work.

ChangeWhat it buys
Type errors stop the build. They're TypeScript 7's errors, from the checker behind bun checkThe types that the compiler starts from agree with each other
Existing members of built-in objects can't be replaced. Array.prototype.push = fn throws a TypeError, in sloppy mode too. Adding is fine: Array.prototype.last = fn. Object.prototype, RegExp.prototype, Promise.prototype and the generator prototypes can't be changed at all. The prototypes of Request, Response and five other Bun classes are read-onlyarr.push(x), map.get(k) and Math.floor(x) compile to the operation, without checking that nobody replaced it
A program with no call to eval or new Function is closed. The compiler finds those calls by following the functions as values, so const run = eval counts. A call that it couldn't follow, like globalThis[name](code), throws an EvalErrorEvery caller of a function is known. Calls are direct, guards go away, and what nothing reaches isn't compiled
The executable holds what your program can reach and nothing else. import() and require() of a computed path find the files that match its constant parts, and the entry points. Other code throws, like a file that doesn't exist. Data files still loadOne file, with no node_modules beside it. Less code to compile, map and page in
A module in the executable runs at most once and isn't in require.cacheAn import is a direct reference to a variable or a function
Each thread can have 32 GB of objects. --aot-huge-heap lifts thatReferences between objects are 32 bits

You shouldn't have to find any of this in production:

your program
bun check --aotisn't run. Reports everything the build would report. With --explain=guards it lists every guard that can fail
bun run --aot, bun test --aotis checked the way the build checks it, then runs from source with built-in objects read-only
--aot-log-type-mismatchesis compiled, and the executable logs every guard that does fail

What it doesn't do

There's no JIT in the executable. All machine code is in the file, mapped read-only and executable like the .text of a C program. On Linux 6.7 and later the executable calls prctl(PR_SET_MDWE) once its code is mapped, and from then on the kernel refuses the process any new executable memory. dlopen(), bun:ffi calls and up to 256 open JSCallbacks keep working. cc() from bun:ffi and native addons that generate machine code don't work there.

Text that shows up at runtime is interpreted. eval, new Function and node:vm run in JavaScriptCore's interpreter, in the same process, on the same objects as the compiled code. A RegExp built from a string at runtime is matched without a JIT. WebAssembly is compiled if it's a .wasm file that the program imports, and interpreted otherwise. --disallow-code-generation-from-strings turns eval off, like Node's flag.

The interpreter is never a fallback for code that the build saw. A function that can't be compiled is a build error.

Code from disk is opt-in. By default a dynamic import() or require() only loads code that's in the executable. --aot-allow-code-from-disk lets it load a plugin or a config file from disk, which is then interpreted. --external <package> does that for one package. The flag has a price, because the compiler can prove less about anything such a file can get hold of: with it, warm Express serves 13% fewer requests per second than in the table above and Koa 16% fewer. The other five don't move. A program with no dynamic import is the same executable either way.

There's no profile and no heap snapshot. The compiler never sees your program run. What's hot is estimated from loops and the call graph. None of your program runs at build time, except macros.

There's no cross-compilation, and breakpoints don't work. The build makes code for the platform it runs on. In an executable built with --compile-inspector, the console, heap snapshots and CPU profiles work. Breakpoints and stepping don't.

PlatformState
Linux x64, glibcTested
Linux arm64, Linux musl, macOS arm64, macOS x64Tested. Some tests still fail
Windows x64, Windows arm64Tested. Many tests still fail
FreeBSD, AndroidBun builds there. Not tested yet

Full types

Publishing a TypeScript library to npm strips the types out of its code. The .d.ts keeps only the ones on its exports. So when you compile an app, your code has types and node_modules doesn't, and that's where a lot of programs spend their time.

Full types is one more file in the package: index.js with the types left in.

codetypesbuilt
src/index.tsall of itall of themno
index.jsall of itnoneyes
index.d.tsnoneexports onlyyes
index.full.tsall of itall of themyes

It sits behind a new condition in exports. Tools skip conditions they don't ask for, so Node, tsc, editors, Vite, webpack, esbuild, bun run and bun build resolve what they resolve today.

"exports": {
  ".": {
    "types": "./index.d.ts",
    "full-types": "./index.full.ts",
    "default": "./index.js"
  }
}

There's one rule: strip the types and you get index.js, exactly. The build checks that every time, against the index.js that's installed. So the file can't hide code and can't go stale, and if you patch index.js, your patch wins. A full-types file that doesn't match or has a type error gets a warning, and the package is compiled without types. A dependency's types never fail your build.

bun compile --library builds the whole package from your TypeScript: index.js, the .d.ts files, index.full.ts and a package.json with the condition in it. packages/full-types has full types for packages that don't ship their own, such as react-dom, Express, Fastify and Koa: annotations added, nothing else changed. docs/bundler/aot-libraries.mdx has the rest.

Smaller executables

A --compile executable is a copy of bun with your program attached, so hello world ships a package manager, a test runner, a type checker and several JIT compilers. Nothing in a compiled program can call those, so --aot always leaves them out.

--trim also leaves out the APIs that your program never uses: bun:sqlite, Bun.Image, HTMLRewriter, Intl data by locale, WebAssembly, the bundler, the debugger. It removes code, not names. Every global, class and property of a removed feature is still there, so typeof, in and instanceof answer as before. A call into one throws ERR_FEATURE_REMOVED and says how to keep it. --explain=trim lists each feature, kept or removed, and why.

You can't delete code from a linked binary, because dead functions are scattered all through .text. So the work happens when bun itself is linked. It's linked once, and the reference graph is read out of the object files' relocations, C++, C and Rust alike. src/trim/features/*.def name the features and the functions you have to pass through to reach each one. Then it's linked again with the removable sections grouped into page-aligned ranges of their own, and a table of them goes into bun. After that, bun build --compile just doesn't map those ranges. Nothing that stays moves, so you don't need a linker or a toolchain.

How it works

Under --aot nothing is bundled. Each module stays a module, a CommonJS body is a function, and Bun's own node: and bun: modules are compiled from their TypeScript sources along with your program.

  1. src/aot/load walks the imports from the entry points with Bun's resolver, loaders, macros and Bun.build plugins, and parses each file once.
  2. src/aot/modules works out the module graph: ES links, which names a CommonJS file exports, the order of evaluation. src/sema/fold folds process.platform and the like, and the code behind them is dropped.
  3. src/sema, the type checker behind bun check, checks each file. src/aot/lower turns its syntax tree and types into an IR in SSA form, file by file, on all threads.
  4. src/aot/link merges the files into one program. src/aot/emit writes it in a format that both repos generate from one schema (aot/ir/AOTSchema.def in the engine).
  5. JavaScriptCore runs a flow analysis over the whole program, optimizes the IR, generates machine code through B3 and writes an image: code plus read-only data.
  6. src/bundler/aot, src/exe_format and src/trim put the image into a copy of bun, with symbols for the compiled functions and without what's trimmed.

One table says what every built-in takes, returns, reads, writes and may call back. src/codegen/builtins makes it from the engine's descriptions and from Bun's own class and module definitions. Bun compiles it, so a change to one of Bun's native classes needs no new build of WebKit.

To start reading: src/aot/compile.rs and src/aot/compile/*_stage.rs are the driver, stage by stage. test/aot/ir/README.md and the files under test/aot/ir/lower/ show a small program and the IR it has to lower to. src/aot/lower/convert and src/aot/lower/layouts are where types turn into IR.

What plain Bun pays

There's one bun, and the compiler is in it. Everyone who installs Bun gets it, whether they compile or not. That's about 30 MB more on disk, and about 23 MB on musl. An executable from plain bun build --compile is a copy of bun, so each of those grows by as much.

This PR also changes code that runs without --aot: node:http, streams, events and process.nextTick in src/js, the checker in src/sema, the executable format, and shared parts of JavaScriptCore. Review matters most there.

Testing

Most of the added lines are tests. test/aot/README.md lists every group of tests, the command that runs it and where a new test goes.

test/aot/programsPrograms, and test/aot/generate writes more when the tests run. Each runs three ways that have to agree: regular Bun, compiled, and compiled with its types erased
test/aot/programs/nothing-throws-because-of-a-type, test/aot/values-against-their-typesValues that don't fit their types, everywhere a type can appear
The variant() rows of test/aot/parts.mtsThe programs again with every pass verified, every function hot, every function cold, no inlining, no flow results, 64-bit references, another code layout
--aot-validateCompiled code checks every fact it relies on at runtime and stops on a miss. The programs run under it too
test/aot/collector-stressThe programs again with a garbage collection at every third slow-path allocation
test/aot/other-suitestest262, JavaScriptCore's tests, Node's tests and Bun's own test/js, each file as an executable against regular Bun on the same file
test/aot/frameworksBuilds and serves Next, Nuxt, Astro, SvelteKit, Angular and React Router apps
test/aot/servers, test/aot/perfThe servers and idioms above
test/aot/analysis-scaling, build-scaling, startup-scalingBuild time and startup can't grow faster than the program
test/aot/trimmed-against-wholeEach program built with and without --trim. Both have to print and exit the same
test/aot/fuzzGenerators and mutators, against the shipping build, a build with assertions and the validating build
test/aot/engine-tests.test.tsThe engine's own native and IR text tests, linked with Bun's table of built-ins
test/cli/aot-docs.test.tsRuns every command in the docs that's followed by its output

Of test262's 47,536 files, 33,467 can run as module code, and 31,231 of those pass. Most of the rest try to change a built-in.

All of it takes 130 hours on one core. So each run takes a share: one program in ten, picked by the commit's hash, so every commit runs a different tenth. CI's blocking steps keep the pick fixed, so the same tree always runs the same tests. AOT_SHARE=all runs everything.

Fuzzers still find programs that it compiles wrong. Known failures are todos in test/aot, with the reason next to each.