Hedronite Lesson · Polyglot-Dev / TypeScript · Mon 2026-09-14

Modules and moduleResolution: bundler

Write ES2015 import and export. Point TypeScript's resolver at the same host Bun uses. That host setting is moduleResolution: bundler.

Lesson Class: Asr (Bun/TypeScript track)
Focus: ESM import/export · moduleResolution bundler · module Preserve · allowImportingTsExtensions · verbatimModuleSyntax · noEmit
Code Blocks: clean blocks, explanation in prose
Done-criteria: Modules and moduleResolution: bundler
Grounding: Cherny Ch10 on disk · bun.com/docs/runtime/typescript · TS Handbook modules
The syntax
ES2015 import/export is the default posture; namespaces stay off the main path.
The host
moduleResolution: bundler matches Bun — exports maps without Node's ESM extension mandate.
The cluster
Preserve + allowImportingTsExtensions + verbatimModuleSyntax + noEmit seat the Bun-shaped checker.
Write ES2015 import and export. Point TypeScript's resolver at the same host Bun uses. That host setting is moduleResolution: bundler.

<!-- hal:authoritative:yaml -->

Write ES2015 import and export. Point TypeScript's resolver at the same host Bun uses. That host setting is moduleResolution: bundler.

§I — Frame

Asr session 04 for the Bun/TypeScript track. Peer fill for 2026-09-14: Nix session 09 (Pills 13 callPackage) already shipped earlier this hour; this bundle authors the missing TypeScript peer only. Session 01 ran .ts under bun run. Session 02 seated strict types under tsc --noEmit. Session 03 seated bun install, bun.lock, and named scripts. Today the track turns to modules: how files export and import, and how the checker is told to resolve those paths the way Bun does.

Done-criteria from the syllabus: Modules and moduleResolution: bundler.

Primary cites: Cherny Programming TypeScript Chapter 10 (Namespaces.Modules) on disk at , plus Bun's live TypeScript page for the suggested compilerOptions block at https://bun.com/docs/runtime/typescript. The TypeScript Handbook Modules theory/reference pages fill what Cherny (2019) could not name yet: the bundler resolution mode. No Bun.serve. No bundling product deep dive (session 12). No @types/bun / TS 6 types: ["bun"] as the main teach (session 08 owns that; today's config cites it only as part of Bun's suggested block).

Home: . Do not dump into Polyglot-Dev/Web/.

§II — ES2015 modules as the default posture

Cherny's standing order in Chapter 10 is blunt: unless you are being chased by wolves, use ES2015 import / export in TypeScript — not CommonJS, not globals, not namespaces for ordinary application code.

Named exports and imports:

// math.ts
export function add(a: number, b: number): number {
  return a + b;
}
export function sub(a: number, b: number): number {
  return a - b;
}

// main.ts
import { add, sub } from "./math";
console.log(add(2, 3), sub(5, 1));

Default export (note the lack of curlies on the import side):

// greeter.ts
export default function greet(name: string): string {
  return `hello, ${name}`;
}

// app.ts
import greet from "./greeter";
console.log(greet("asr"));

Wildcard import and reexport:

import * as math from "./math";
math.add(1, 2);

export { add } from "./math";
export * from "./math";

Types and values live in separate namespaces. Cherny shows you may export a value and a type that share a name; the checker picks which one you meant from position:

// dual.ts
export let X = 3;
export type X = { y: string };

// use.ts
import { X } from "./dual";
let a = X + 1;            // value X
let b: X = { y: "z" };    // type X

Module paths are filenames on the filesystem. That couples the module graph to layout — and that is intentional: loaders and bundlers resolve names to files.

Cherny's other two modes to recognize and mostly avoid for this track:

  1. **Dynamic import()** — await import("./locale") returns a Promise for the module. Useful for lazy load. String literals keep type safety; computed paths need a manual type annotation (Cherny's typeof scavenger pattern). Hold deep code-splitting as "exists; later."
  2. Namespaces — the namespace keyword merges declarations and hides file layout. Cherny allows them for small browser sketches; for medium+ projects and for this Bun track, stick to ES modules.

Module mode vs script mode. TypeScript parses each file in one of two modes. Heuristic: if the file has any import or export, it is module mode; otherwise script mode (shared global scope). Asr lessons stay in module mode. A file with no imports/exports that you meant as a module is a script — and that is how accidental globals sneak in.

CommonJS interop (recognize, do not live in). When consuming a legacy CJS package, Cherny notes default exports may need import * as fs from "fs" unless esModuleInterop is on, after which import fs from "fs" can work. Bun's suggested config implies synthetic default import behavior via the bundler-mode stack. Prefer writing ESM yourself; treat CJS as something you consume, not something you author on this track.

§III — What moduleResolution: bundler means

Module syntax is only half the story. Module resolution is how a specifier string ("./math", "lodash", "bun:sqlite") becomes a file (or package entry) the host will load. The ECMAScript spec does not define that lookup. Runtimes and bundlers do. TypeScript imitates the host so the checker agrees with runtime.

Cherny's Chapter 10 sidebar only knew two moduleResolution values:

That map is incomplete in 2026. The Handbook now names node16 / nodenext for Node's strict ESM rules (relative imports need extensions) and **bundler** for hosts that took Node's package.json "exports" / "imports" support without adopting Node's strict "extensions required on ESM relative imports" rule.

Handbook line you need: for bundlers and the Bun runtime, the matching setting is moduleResolution: "bundler". It supports package.json "exports" / "imports" like node16/nodenext, while always allowing extensionless relative imports. Pair it with "module": "esnext" or "module": "Preserve". Bun's docs choose **Preserve**.

Why not nodenext on this track? Because Bun is not enforcing Node's ESM extension mandate on every relative import the way Node does. If you set nodenext, the checker will demand ./math.js (or similar) shapes that fight Bun's extensionless / .ts-extension habits. Match the host you actually run.

§IV — Bun's suggested compilerOptions block

Bun's TypeScript docs recommend a tsconfig.json that makes the checker stop fighting features Bun already runs: top-level await, JSX, and imports with .ts extensions. The Bundler mode cluster is today's Done-criteria:

{
  "compilerOptions": {
    "lib": ["ESNext"],
    "target": "ESNext",
    "module": "Preserve",
    "moduleDetection": "force",
    "jsx": "react-jsx",
    "allowJs": true,
    "types": ["bun"],

    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "noEmit": true,

    "strict": true,
    "skipLibCheck": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true
  }
}

bun init writes this shape for you. Read the four levers as one sentence:

  1. **moduleResolution: "bundler"** — resolve like Bun/bundlers (exports map + extensionless relatives).
  2. **module: "Preserve"** — leave import/export syntax alone for the host; do not rewrite ESM into CJS in the typeworld.
  3. **allowImportingTsExtensions: true** — permit import { add } from "./math.ts". Requires noEmit (or emitDeclarationOnly) because tsc will not rewrite .ts → .js in emit, and a raw .ts specifier in emitted JS would be unsafe for Node. Bun loads .ts directly, so the pair is coherent here.
  4. **verbatimModuleSyntax: true** — value imports stay; type-only imports must say import type / export type and are erased. Stops accidental elision games and CJS/ESM rewrite surprises.
import type { User } from "./types.ts";
import { loadUser } from "./db.ts";

noEmit: true keeps session 01's split intact: Bun executes (strips types). tsc --noEmit (or bunx tsc --noEmit / a "typecheck" script from session 03) checks. You are not asking tsc to emit a parallel JS tree for this Asr seat.

types: ["bun"] appears in Bun's block so the Bun global resolves after @types/bun is installed. Session 08 owns the TS 6 discovery change; today just leave the field in the recommended config and do not digress.

Minimal two-file proof under that config:

// src/math.ts
export function add(a: number, b: number): number {
  return a + b;
}

// src/index.ts
import { add } from "./math.ts";
console.log(add(2, 40));
bun run src/index.ts
bunx tsc --noEmit

Both should succeed when tsconfig.json carries the bundler cluster above. If tsc errors on the .ts extension, allowImportingTsExtensions or noEmit is missing. If it errors on a bare ./math under some other config, you may have pointed resolution at nodenext by mistake — switch back to bundler for this track.

§V — Self-check

Proof 1. ESM posture. Write two .ts files with a named export and a matching import { … } from "./…". Run them with bun run. State Cherny's default: ES2015 modules, not namespaces, for ordinary app code.

Proof 2. Bundler setting. Open or create tsconfig.json. Confirm "moduleResolution": "bundler" and "module": "Preserve" (or esnext). Say in one sentence why nodenext is the wrong host model for Bun on this track.

**Proof 3. .ts extensions.** With allowImportingTsExtensions + noEmit, import a sibling using an explicit .ts specifier. Confirm bun run executes and tsc --noEmit is clean.

**Proof 4. import type.** Under verbatimModuleSyntax, import a type with import type and a value with a plain import. Confirm a plain import of a type-only symbol is rejected (or fixed) the way the flag intends.

Proof 5. Mode heuristic. Delete all imports/exports from a file that was a module and explain what mode TypeScript switches to (script mode) and why that is a footgun.

If any proof fails, re-read Bun's TypeScript page and the Handbook's bundler paragraph. Do not "fix" it by turning off strict or by switching to moduleResolution: node10.

Closing

Session 04 seats modules beside the Bun-shaped resolver. You wrote ES2015 import / export as Cherny requires. You named moduleResolution: "bundler" as the host model for Bun, paired with module: "Preserve", allowImportingTsExtensions, verbatimModuleSyntax, and noEmit. You kept the execute/check split: Bun runs the graph; tsc --noEmit verifies it under the same resolution rules.

Name the mechanism when someone asks what "modules and bundler resolution" means on this track: ESM syntax in the files, and a checker configured to resolve those specifiers the way Bun does — not the way Node's strict ESM algorithm does.

Session 05 is Bun.serve HTTP. Do not start it in this folder. Do not write it today.

Examine well. Import and export are the first proof. Bundler resolution is the second. .ts extensions under noEmit are the third. import type under verbatim syntax is the fourth. Module-vs-script awareness is the fifth. The door is a small graph that runs under bun run and typechecks under the Bun-suggested tsconfig.

Related