What happens when Bun runs a TypeScript file
By Flavio Copes
Follow bun run app.ts through CLI dispatch, module resolution, TypeScript transpilation, JavaScriptCore, Node compatibility, and the event loop.
One of the nicest things about Bun is that I can write a TypeScript file and run it directly:
const message: string = 'hello'
console.log(message)
bun run app.ts
There is no separate build command, no tsc step, and no generated JavaScript file I have to run afterward.
It looks like Bun runs TypeScript, but no JavaScript engine can execute message: string. That annotation has to go away before the code reaches the engine.
So what happens between the command and the first console.log()?
I followed that path through the Bun source code. Here is the short version:
bun run app.ts
↓
CLI decides this is a source file
↓
Bun creates its runtime and JavaScriptCore VM
↓
the module loader resolves app.ts
↓
Bun parses TypeScript and prints JavaScript
↓
JavaScriptCore evaluates the module
↓
Bun's event loop keeps the process alive while work remains
Let’s go through each step.
First, Bun decides what app.ts means
The run command accepts several kinds of input.
These two commands look similar, but they can take different paths:
bun run app.ts
bun run dev
In the first command, app.ts looks like a file. Bun can start its runtime and load that file directly.
In the second command, dev might be a script in package.json:
{
"scripts": {
"dev": "astro dev"
}
}
Bun has to find the closest package.json, look for a matching script, prepare the script environment, and run the command through its shell machinery.
Keep this in mind when something odd happens at startup. Running a file and running a package script are two different operations, even if both start with bun run.
How does Bun tell them apart? It looks at the first argument. If it’s a path, ends with a source extension like .ts, or matches an existing file, Bun treats it as a file to run. app.ts passes that check, so Bun boots the JavaScript runtime.
Bun prepares the process before loading the file
Before it reads app.ts, Bun collects the configuration around it.
This can include:
- command-line flags
bunfig.toml- environment files
tsconfig.jsonorjsconfig.json- preload modules
- the current working directory
- process arguments
Some of these change how imports get resolved. Others change which globals and environment variables the program sees.
That’s why the same app.ts can behave differently in two different folders. The project around the file matters as much as the file.
A few file types never reach the JavaScript engine at all. A shell script, for example, goes down a different route. A .ts file does need the engine, so at this point Bun initializes JavaScriptCore.
JavaScriptCore is only the engine
Bun uses JavaScriptCore, the JavaScript engine from WebKit.
JavaScriptCore executes JavaScript. It parses the code, creates objects and functions, runs the bytecode or the optimized machine code, manages memory, and does garbage collection.
That’s a lot, but it’s not a server-side runtime. JavaScriptCore on its own has none of these:
Bun.file()Bun.serve()process- Node.js built-in modules
- timers
fetch()- the filesystem APIs
- the event loop that coordinates asynchronous work
Bun builds those parts around the engine.
During startup, Bun creates a virtual machine and a global object for the program. It installs native bindings, creates the module loader, prepares the transpiler, and creates the event loop.
Roughly, it looks like this:
Bun runtime
├── JavaScriptCore
├── module resolver and loader
├── TypeScript/JSX transpiler
├── Web and Node-compatible APIs
├── native I/O implementations
└── event loop
Most of Bun lives in those other layers, not in the engine.
Bun does not hand app.ts directly to JavaScriptCore
Once the runtime exists, Bun asks its module loader to load the entry point.
Internally, Bun creates a small synthetic entry module. In the source this is called bun:main.
That module imports your file. It also gives Bun a place to add some behavior around your entry point without touching your code.
The server shortcut is one example. If your default export looks like a server configuration object, Bun passes it to Bun.serve() for you. This file starts a server:
export default {
fetch() {
return new Response('hello')
},
}
Nothing in the file calls Bun.serve(). The generated entry module sees the export and does it.
Before the main module, Bun also runs any preload modules set with --preload or in bunfig.toml. A preload can register globals, instrument code, or change state before app.ts starts.
If a program does something before its first line seems to run, check the preloads.
The resolver turns an import into a file
Suppose app.ts contains this import:
import { greet } from './greet'
greet('Flavio')
./greet has no extension. Is it greet.ts? greet.js? greet/index.ts? Bun has to decide before the engine can do anything with this module.
The resolver looks at the importing file, the specifier, the supported extensions, package metadata, aliases, and tsconfig.json paths. Depending on the import, it may need to check:
- relative files
- directories and index files
package.jsonexportsnode_modulestsconfig.jsonpath mappings- Bun built-ins such as
bun:test - Node built-ins such as
node:fs
Resolving is one step. Loading is the next. Once Bun knows which file ./greet is, the loader decides how to process it, mostly based on the extension:
.js → JavaScript
.jsx → JavaScript with JSX
.ts → TypeScript
.tsx → TypeScript with JSX
.json → JSON
There are more loaders, but the idea is this: the loader tells Bun what syntax to expect and how to turn the file into something JavaScriptCore can run.
TypeScript is transpiled, not type-checked
For a .ts file, Bun parses TypeScript syntax and generates JavaScript.
The types disappear:
type User = {
name: string
}
const user: User = {
name: 'Flavio',
}
console.log(user.name)
The engine receives the equivalent of:
const user = {
name: 'Flavio',
}
console.log(user.name)
This is fast because it’s only a syntax transformation. Bun removes the types without checking that they are right.
Which means this program runs:
const port: number = '3000'
console.log(port)
tsc would complain: a string assigned to a number. Bun strips the annotation and prints 3000.
If I want type checking, I still run the TypeScript compiler:
tsc --noEmit
So there are two jobs:
Bun → run the program
TypeScript → check the program
I don’t have to wait for one before doing the other. I run the program with Bun right away, and my editor, a second terminal, or CI does the type checking.
The transpiler produces JavaScript for the engine
Bun’s transpiler parses the source into an internal syntax tree, applies the transformations required by the loader and configuration, and prints JavaScript.
For TypeScript, that includes removing type-only syntax. For TSX, it also transforms JSX. Other transformations can include handling module syntax, injected definitions, and source maps.
Bun doesn’t need to write a .js file to disk for this. The JavaScript can be generated in memory, as part of loading the module, and handed to JavaScriptCore.
Every import goes through the same process. A program with 100 modules is not one giant file. It’s a graph:
app.ts
├── config.ts
├── server.ts
│ ├── router.ts
│ └── logger.ts
└── package from node_modules
Each arrow is a resolution. Each file goes through its loader. Then everything gets linked before evaluation starts.
This is why a module-resolution error can point at a file you never mentioned on the command line. Bun is building a graph, not reading app.ts top to bottom.
JavaScriptCore evaluates the module graph
After Bun has resolved and transformed the module, JavaScriptCore can parse the generated JavaScript and evaluate it.
At this point normal JavaScript semantics take over:
- imported modules are linked
- module bodies are evaluated
- functions and objects are created
- promises schedule microtasks
- exceptions propagate
- top-level
awaitcan suspend module evaluation
When the program calls a Bun API, execution leaves JavaScriptCore and enters Bun’s native code. For example:
const file = Bun.file('message.txt')
const text = await file.text()
console.log(text)
JavaScriptCore runs the JavaScript. Bun reads the file and settles the promise when the I/O is done. This back and forth happens all the time while a program runs.
Node compatibility is another layer
Many Bun programs import Node APIs:
import { readFile } from 'node:fs/promises'
const text = await readFile('message.txt', 'utf8')
console.log(text)
JavaScriptCore knows nothing about node:fs/promises. Bun implements it, along with the other Node modules and globals, so existing packages can run. Some of these APIs call into native code, some are written in JavaScript, and some differ from Node in edge cases.
This helps when a package breaks under Bun. The engine is almost never the problem, and the question is usually one of these:
- Did Bun resolve the same module Node would resolve?
- Does Bun implement the Node API the package uses?
- Does the package depend on an undocumented Node behavior?
- Does it load a native addon with assumptions specific to Node?
- Does timing differ around streams, processes, or the event loop?
The event loop decides when the process is finished
Evaluating the entry module does not always mean the program is done.
This exits quickly:
console.log('done')
This does not:
setInterval(() => {
console.log('still here')
}, 1000)
The interval is live work. Bun’s event loop keeps going while there are timers, pending I/O, server sockets, or other referenced handles.
Promises sit in between. JavaScriptCore owns the microtask queue, and Bun coordinates it with the rest of the loop.
Simplified, the loop does this:
run ready JavaScript
drain promise microtasks
process timers and completed I/O
run newly scheduled callbacks
check whether referenced work remains
repeat or exit
The real thing has more queues and more edge cases, but this explains most of what you see. A server stays up because its socket is listening, and a pending fetch() counts as work too. An unreferenced timer does not keep the process alive, while a top-level await holds things until its promise settles. When nothing is left, Bun exits.
Running is not bundling
bun build and bun run share the parser, the resolver, and the transpiler. So it’s tempting to think of bun run as a bun build followed by execution. It isn’t.
bun build produces artifacts. It can combine modules, rewrite paths, split chunks, and write files for another environment.
bun run app.ts loads the entry point into the current runtime and evaluates the graph. It transforms source on the way, but nothing is meant to be written out or shipped. The goal is to run the program now.
The complete path
Here is the full diagram:
bun run app.ts
↓
parse CLI arguments
↓
classify app.ts as a source entry point
↓
load bunfig, environment, tsconfig, and preloads
↓
initialize JavaScriptCore, the Bun VM, bindings, and event loop
↓
generate the internal bun:main entry module
↓
resolve app.ts and choose the TypeScript loader
↓
parse TypeScript and print JavaScript
↓
link and evaluate the module graph in JavaScriptCore
↓
cross into Bun APIs whenever the program needs runtime services
↓
keep ticking until no live work remains
Bun never executes TypeScript. It strips the types and runs the JavaScript, like any other runtime would. It feels like one command because Bun owns every piece of this pipeline: the CLI, the resolver, the transpiler, the runtime APIs, the engine integration, and the event loop. You don’t have to wire anything together.
If you want to read the code, it’s in the Bun repository. Start from the run command, then the VM setup, the module loader, the transpiler, and the event loop.
Want me to talk about your product? You can sponsor this site.
Related posts about js: