← Back to list

Zero to Bundler: Let’s Build a Modern JavaScript Bundler from Scratch with TypeScript | Part 2…

In Part 1, we established what a bundler does: discover, transform, link. Now we wire up the machinery that runs those three steps.

HichamElMefeddel · 2026-06-19 03:28 · 3 claps · 6.8 min read
#bundler #typescript #webpack #javascript #frontend
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Zero to Bundler: Let’s Build a Modern JavaScript Bundler from Scratch with TypeScript | Part 2: Designing the Compiler Pipeline

In Part 1, we established what a bundler does: discover, transform, link. Now we wire up the machinery that runs those three steps.

Every serious bundler is structured as a compiler pipeline, a sequence of stages, each with a single responsibility. Graphite is no different. Today we build the skeleton: the CLI, the Compiler, and the Compilation that orchestrates a full build.

Index of our serie:

  • Part 1: Why building a bundler ?
  • Part 2: The compiler pipeline. (← You are here)
  • Part 3: Parsing Imports and Exports
  • Part 4: The module Graph
  • Part 5: Resolving Specifiers
  • Part 6: Emitting your first Bundle
  • Part 7: Tree-shaking without Magic
  • Part 8: Dev Server and HMR

The CLI: your front door

npx do-something, hurry-up!

npx do-something, hurry-up!

Everything starts with a command. We’ll support in Graphite these commands.

npx ts-node src/cli.ts build --entry examples/basic/index.ts --outDir dist
npx ts-node src/cli.ts dev   --entry examples/basic/index.ts --outDir dist

The CLI (src/cli.ts) is deliberately thin. It parses arguments, prints a banner, and delegates:

- `build` → creates a `Compiler` and calls `run()`
- `dev` → creates a `DevServer` and calls `start()`

The compiler in our Typescript code will be instantiated this way.

const compiler = new Compiler({
  entry,
  outputDir: outDir,
  outputFile: outFile,
  dev: hasFlag("--dev"),
});
compiler.run();

No parsing. No graph building. No emission. The CLI’s only job is to start the right subsystem with the right options.

This separation matters. In production bundlers, the CLI is often thousands of lines. Keeping it thin means you can test the compiler without invoking a CLI, and you can invoke the compiler programmatically without a CLI at all.

The Compiler: configuration owner

Hold on, don’t panic, compiler is just a simple Class we’ll have and this how it will look like!! (don’t ever fear the fancy terms like “Compiler”, or “Compilation”)

Hold on, don’t panic, compiler is just a simple Class we’ll have and this how it will look like!! (don’t ever fear the fancy terms like “Compiler”, or “Compilation”)

The Compiler class (src/compiler/Compiler.ts) owns the build configuration and creates compilations:

export class Compiler {
  private readonly options: CompilerOptions;

  constructor(options: CompilerOptions) {
    this.options = options;
  }ttyp

  run(): void {
    const compilation = this.createCompilation();
    compilation.run();
  }

  private createCompilation(): Compilation {
    return new Compilation(this.options);
  }
}

Notice what’s missing: the Compiler doesn’t parse files, doesn’t build graphs, doesn’t emit bundles. It creates a Compilation and runs it.

Why separate Compiler from Compilation?

  • Compiler: long-lived configuration (entry point, output paths, dev mode)
  • Compilation: a single build execution (graph → optimize → emit)

This mirrors how real compilers work. rustc for example, has a Compiler and a Compilation. TypeScript as another example, has a Program and an EmitResult. The pattern scales: later you can run multiple compilations from one compiler (watch mode, parallel builds) without re-parsing configuration.

The Compilation: where the work happens

Illustration about compiler/compilation and how to think about them.

Illustration about compiler/compilation and how to think about them.

If Compiler is the architect, then Compilation is the project manager.

It doesn’t parse JavaScript. It doesn’t resolve modules. It doesn’t optimize code. It doesn’t generate bundles.

Instead, its job is much simpler and much more important. It coordinates all of those independent subsystems.

Think of it as the conductor of an orchestra. The conductor doesn’t play the violin, the piano, or the drums. Instead, they know when each instrument should play and how they all fit together into a single performance.

Our Compilation follows exactly the same philosophy.

A single call to run() executes the entire bundling pipeline:

  1. Build the module graph (GraphBuilder)
  2. Run optimizations (TreeShaker)
  3. Emit the bundle (BundleEmitter)

In code, it looks roughly like this:

run(): void {
  const graph = this.buildGraph();
  const usedExports = this.optimize(graph);
  this.emit(graph, usedExports);
}

Every stage of the pipeline is implemented as its own class with a well-defined responsibility and a simple interface.

This isn’t accidental it’s one of the core design principles behind Graphite.

The Compilation class doesn't know how to parse a file. It doesn't know how to resolve a module specifier, rewrite an import, or generate JavaScript. Those details belong to the subsystems that specialize in those tasks.

Instead, Compilation focuses on orchestration.

It knows when each subsystem should run.

It knows what data each subsystem needs as input.

And it knows what the next stage expects as output.

That’s it.

This separation of concerns gives every class a single responsibility:

  • Parser understands source code.
  • Resolver finds modules on disk.
  • GraphBuilder constructs the dependency graph.
  • TreeShaker analyzes and removes unused exports.
  • BundleEmitter generates the final JavaScript bundle.

The Compilation simply connects these pieces together into a coherent pipeline.

By keeping orchestration separate from implementation, every subsystem remains independent, easier to test, and easier to replace. If we decide to implement a faster parser or a different optimization strategy later in the series, the Compilation doesn't need to change, it continues orchestrating the pipeline exactly the same way.

This design is one of the reasons Graphite stays understandable as it grows. Every class has one job, every subsystem has clear boundaries, and data flows predictably from one stage to the next.

The pipeline, visualized

Quick mindmap explaining where we are right now

Quick mindmap explaining where we are right now

We’ll build every box in this diagram over the next chapters of the series. For now, don’t worry about the implementation details — focus on understanding how information flows through the system.

Everything starts with a simple command in the terminal:

CLI
  ↓
Compiler
  ↓
Compilation
  ↓
Build Graph → Optimize → Emit

That’s the entire mental model.

Every feature we’ll implement from parsing modules and resolving imports to tree shaking and bundle generation fits somewhere into this pipeline.

As we continue building Graphite, each box will gradually evolve from a simple placeholder into a fully-fledged subsystem. By the end of the series, you’ll not only understand every stage of the compilation pipeline — you’ll have implemented every one of them yourself.

CompilerOptions: the contract

Everything the pipeline needs flows through a single options object:

interface CompilerOptions {
  entry: string;       // e.g. "examples/basic/index.ts"
  outputDir: string;   // e.g. "dist"
  outputFile: string;  // e.g. "bundle.js"
  dev: boolean;      // enable HMR hooks in emitted runtime
}

One principle we’ll consistently respect throughout this series is that dependencies should always be explicit.

We’ll avoid relying on global state. We won’t read environment variables from deep inside the BundleEmitter, nor will we hide configuration behind singletons or static objects.

Instead, every subsystem will receive exactly the dependencies it needs when it’s constructed.

This approach makes the flow of data obvious. We can look at any class and immediately understand what it depends on, what it produces, and how it fits into the compilation pipeline.

Just as importantly, it makes our code easy to test.

We can instantiate a Compiler with a known configuration, call run(), and verify the generated output. There are no hidden dependencies to mock, no implicit global state to reset, and no unexpected side effects behind the scenes.

As our bundler grows throughout this series, we’ll continue following this philosophy:

make dependencies explicit, keep responsibilities focused, and let data flow predictably from one stage to the next.

What we have so far

By the end of this part, we’ve established the architectural foundation of our bundler.

We now have:

  • A CLI capable of accepting build and dev commands.
  • A Compiler responsible for owning and validating the project configuration.
  • A Compilation that orchestrates the entire compilation pipeline — from building the dependency graph, through optimization, all the way to bundle emission.
  • Well-defined pipeline stages, each with its own responsibility and clear interface, ready to be implemented as the series progresses.

At this point, we don’t have a fully functioning bundler, and that’s perfectly fine.

What we do have is something arguably more important: a clean architecture.

We’ve defined the responsibilities of each subsystem, established how data flows through the pipeline, and built a foundation that will allow every new feature to fit naturally into the design.

In the next part, we’ll move from architecture to implementation. We’ll build the first real stage of the pipeline, the compiler pipeline itself and start turning this skeleton into a working bundler, one subsystem at a time.

Zooming Back Out

Think of this as our blueprint. Every chapter adds another subsystem until the entire architecture comes together into our end version of a bundler.

Think of this as our blueprint. Every chapter adds another subsystem until the entire architecture comes together into our end version of a bundler.

We’ve spent this article focused on one piece of the architecture. Before moving on, let’s zoom back out and look at the project as a whole.

The diagram below represents the destination we’re building toward throughout this series. Each article fills in another box, another folder, and another subsystem until the entire architecture is complete.

Think of it as a map of the journey — we’ll revisit it at the end of every chapter to see how far we’ve come.

What’s next

In Part 3, we’ll build the Parser, the first real stage of our compilation pipeline.

Using the TypeScript Compiler API, we’ll parse source files into Abstract Syntax Trees (ASTs) and learn how to traverse them to discover every import and export. This is our first step into static analysis, where we begin understanding the structure of JavaScript instead of treating it as plain text.

From there, everything starts to come together.

The information extracted by the parser will become the foundation for building the module graph, resolving dependencies, eliminating unused code through tree shaking, and eventually supporting advanced features like incremental builds and Hot Module Replacement.

We’re no longer just designing the architecture, we’re about to start bringing it to life, one subsystem at a time.

If you’d like to follow along or explore the code as it evolves throughout the series, you can find the complete source code on GitHub.

Source Code: https://github.com/hel-mefe/graphite


메타데이터
post_id
2ec00bca5912
slug
zero-to-bundler-lets-build-a-modern-javascript-bundler-from-scratch-with-typescript-part-2-2ec00bca5912
url
https://medium.com/@hichamelmefeddel/zero-to-bundler-lets-build-a-modern-javascript-bundler-from-scratch-with-typescript-part-2-2ec00bca5912
canonical_url
https://medium.com/@hichamelmefeddel/zero-to-bundler-lets-build-a-modern-javascript-bundler-from-scratch-with-typescript-part-2-2ec00bca5912
author_url
https://medium.com/@hichamelmefeddel
status
ok
fetched_at
2026-06-21 21:05:38