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.
<!-- 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:
- **Dynamic
import()** —await import("./locale")returns aPromisefor the module. Useful for lazy load. String literals keep type safety; computed paths need a manual type annotation (Cherny'stypeofscavenger pattern). Hold deep code-splitting as "exists; later." - Namespaces — the
namespacekeyword 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:
- **
node** — always use this (in 2019): Node-likenode_moduleslookup, relative./paths, extension search including.ts/.tsx/.d.ts/.js. - **
classic** — never use this: surprising walk-up search for bare names.
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:
- **
moduleResolution: "bundler"** — resolve like Bun/bundlers (exports map + extensionless relatives). - **
module: "Preserve"** — leave import/export syntax alone for the host; do not rewrite ESM into CJS in the typeworld. - **
allowImportingTsExtensions: true** — permitimport { add } from "./math.ts". RequiresnoEmit(oremitDeclarationOnly) becausetscwill not rewrite.ts→.jsin emit, and a raw.tsspecifier in emitted JS would be unsafe for Node. Bun loads.tsdirectly, so the pair is coherent here. - **
verbatimModuleSyntax: true** — value imports stay; type-only imports must sayimport type/export typeand 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
- Syllabus: Asr Bun/TS syllabus, session 04
- Syllabus: Asr Nix syllabus, peer track
- Grounding (on-disk): Cherny Programming TypeScript Ch10
- Grounding (live): https://bun.com/docs/runtime/typescript
- Grounding (live): https://www.typescriptlang.org/docs/handbook/modules/theory.html
- Grounding (live): https://www.typescriptlang.org/docs/handbook/modules/reference.html
- Prior fire: Asr session 03 · Bun as package manager
- Prior fire: Asr session 02 · TypeScript strict basics
- Prior fire: Asr session 01 · Bun runtime basics
- Nix peer (same day): Asr session 09 · callPackage
- Next fire: session 05, Bun.serve HTTP (unwritten)