What happens when Bun runs a TypeScript file

By

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. No generated JavaScript file I have to run afterward.

It feels as if Bun executes TypeScript.

But a JavaScript engine cannot execute TypeScript syntax. The type annotation in message: string has to disappear before the program reaches the engine.

So what actually happens between the command and the first console.log()?

I followed that path through the Bun source code.

The short version is this:

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

Each box hides an interesting detail.

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.

This distinction matters when debugging startup behavior. Running a TypeScript entry point is not the same operation as running an arbitrary package script, even though both start with bun run.

The Bun CLI examines the first positional argument. A path, a recognized source extension, or an existing file sends it toward the source-file path.

For app.ts, Bun knows it needs to boot 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.json or jsconfig.json
  • preload modules
  • the current working directory
  • process arguments

Some of those settings affect how imports will be resolved. Others affect the globals and environment visible to the program.

This is why two identical app.ts files can behave differently in two directories. The entry file is only one part of the program. The surrounding project configuration participates in loading it.

Bun also handles a few file types before it needs a JavaScript engine. A shell script, for example, can take a different runtime path.

For TypeScript, it initializes JavaScriptCore.

JavaScriptCore is the engine, not the entire runtime

Bun uses JavaScriptCore, the JavaScript engine from WebKit.

JavaScriptCore is responsible for executing JavaScript. It parses JavaScript, creates objects and functions, runs the bytecode or optimized machine code, manages memory, and performs garbage collection.

It does not give Bun everything developers expect from a server-side runtime.

For example, JavaScriptCore alone does not provide Bun’s implementations of:

  • 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.

A useful mental model is:

Bun runtime
├── JavaScriptCore
├── module resolver and loader
├── TypeScript/JSX transpiler
├── Web and Node-compatible APIs
├── native I/O implementations
└── event loop

Calling Bun “JavaScriptCore with a CLI” misses most of the work that makes it a runtime.

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 generated module imports the file the user requested. It also gives Bun a place to add entry-point behavior without rewriting the user’s file.

One example is Bun’s server shortcut. If the default export looks like a server configuration object, Bun can pass it to Bun.serve() automatically.

This works:

export default {
  fetch() {
    return new Response('hello')
  },
}

The file does not explicitly call Bun.serve(), but the generated entry-point code can recognize the export and start the server.

The extra entry layer is invisible in normal use. It is still part of the execution path.

Before loading the main module, Bun also evaluates any preload modules configured with --preload or in bunfig.toml. A preload can register globals, instrument code, or change state before app.ts runs.

If startup behaves differently from what the entry file suggests, preloads are one of the first places to look.

The resolver turns an import into a file

Suppose app.ts contains this import:

import { greet } from './greet'

greet('Flavio')

The engine cannot evaluate that module until Bun decides what ./greet refers to.

The resolver considers the importing file, the specifier, supported extensions, package metadata, aliases, and TypeScript path configuration.

Depending on the import, it may need to inspect:

  • relative files
  • directories and index files
  • package.json exports
  • node_modules
  • tsconfig.json path mappings
  • Bun built-ins such as bun:test
  • Node built-ins such as node:fs

Resolution and execution are separate steps. First Bun turns a specifier into a concrete module. Then the loader decides how to process that module.

The file extension helps select the loader:

.js   → JavaScript
.jsx  → JavaScript with JSX
.ts   → TypeScript
.tsx  → TypeScript with JSX
.json → JSON

Bun supports more loaders, but these show the important point: the loader tells the runtime what syntax it should expect and how to turn the source into something JavaScriptCore can use.

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 step is fast because it is a syntax transformation. Bun does not need to prove that the program is type-correct before running it.

That also means this program can run:

const port: number = '3000'

console.log(port)

TypeScript reports an error because a string is assigned to a number. Bun removes the annotation and executes the resulting JavaScript.

If I want type checking, I still run the TypeScript compiler:

tsc --noEmit

This separation is intentional:

Bun          → run the program
TypeScript   → check the program

The two jobs do not have to block each other. During development I can run Bun immediately and run type checking separately in my editor, a second terminal, or CI.

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.

This does not necessarily mean Bun writes a .js file to disk.

The transformation is part of the module-loading pipeline. Bun can generate the JavaScript representation in memory and give it to JavaScriptCore.

Imports go through the same general process as they are loaded. A program with 100 modules is not one giant TypeScript file handed to an engine. It is a graph:

app.ts
├── config.ts
├── server.ts
│   ├── router.ts
│   └── logger.ts
└── package from node_modules

Each edge has to be resolved. Each source module has to be loaded with the appropriate loader. Each module has to be linked into the graph before its evaluation rules can be followed.

This is one reason module-resolution bugs can feel unrelated to the line that starts the application. The runtime is building and evaluating a graph, not reading a single file from 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 await can suspend module evaluation

When the program calls a Bun API, execution can cross from JavaScriptCore into Bun’s native implementation.

For example:

const file = Bun.file('message.txt')
const text = await file.text()

console.log(text)

JavaScriptCore executes the JavaScript calls. Bun implements the file operation and arranges for the promise to settle when the I/O completes.

The boundary is crossed constantly. The engine runs JavaScript. The runtime provides the world around it.

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 does not implement node:fs/promises.

Bun does.

It exposes Node-compatible modules and globals so existing packages can run. Some APIs call Bun’s native code. Some are implemented in JavaScript. Some reproduce Node behavior closely, while edge cases can still differ.

This distinction helps when a package fails under Bun.

The question is usually not “Can JavaScriptCore run this JavaScript?”

It is more likely 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?

Engine compatibility and runtime compatibility are different problems.

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 creates live work. Bun’s event loop keeps ticking while timers, I/O operations, server sockets, pending tasks, or other referenced handles remain.

Promises add another layer. JavaScriptCore owns the microtask queue used by promises, while Bun coordinates it with the rest of the runtime work.

A simplified loop looks like 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 implementation has more queues and more edge cases, but this model explains most visible behavior.

A server stays alive because its listening socket is live. A pending fetch keeps work in flight. An unreferenced timer may not keep the process alive. A top-level promise can delay completion while its dependencies are resolved.

When no live work remains, Bun runs its exit path and the process ends.

Running is not bundling

Bun uses parts of its parser, resolver, and transpiler in several features. That can make bun run sound like a hidden bun build followed by execution.

That is not a good model.

bun build produces build 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 module graph. It transforms source as required, but its goal is to run the program now.

The shared machinery does not make the operations identical.

The complete path

We can now expand the original 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

There is no magic TypeScript execution.

There is a carefully connected pipeline.

Bun makes the pipeline feel like one operation because it owns the CLI, resolver, transpiler, runtime APIs, engine integration, and event loop. The user does not have to assemble those pieces.

That is the real convenience of bun run app.ts.

The types disappear before execution. The setup disappears from the command line. The complexity is still there, but Bun carries it for us.

The implementation lives in the Bun repository. The most useful areas to follow are the run command, the virtual machine 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: