Modules & Resolution
A module in TypeScript is not syntactic decoration — it is the boundary between what you write and what the runtime executes. There is exactly one rule: a file with a top-level import or export is a module with its own scope; a file with none is a script whose declarations land in the global scope. Almost every «duplicate identifier» and «cannot find name» grows from that single fact, and the export {} idiom exists to force a file into module-hood. On top of it sit two orthogonal axes people constantly conflate: the module option decides what syntax tsc emits (commonjs rewrites import/export into require/exports, esnext leaves them as ES modules), while moduleResolution decides how a specifier string like 'lodash' is turned into a file on disk. Neither one changes what you wrote in the source.
Hence the trap board, worth naming upfront. Types in TypeScript are erased — so import type is guaranteed to emit nothing, and a paths alias never reaches the runtime and needs a separate resolver. CommonJS has no notion of a default export — so esModuleInterop synthesises one with a helper, and without it import fs from 'fs' compiles but fails. namespace is a pre-module relic, alive only inside .d.ts. And versions keep cutting: TS 6.0 deprecated moduleResolution: node10, baseUrl, outFile and esModuleInterop: false, and TS 7.0 (GA 8 July 2026, a Go-native compiler) turned them into hard errors. The layer-by-layer breakdown is below.
Topic map
- A module file versus a script file — the "top-level
import/export→ module" rule, static analysability of ESM, tree-shaking, and why a circular import crashes under CommonJS. - CommonJS interop — how
module.exports = xdiffers fromexport default x, whatesModuleInteropdoes, and whyimport * as expressstops being callable. - Module resolution —
node10versusnode16/nodenextversusbundler,pathsas compile-time only, and why an alias throws at run time. - import type and erasure — why
tscelides a type import while a single-file transpiler cannot, and whatisolatedModulesandverbatimModuleSyntaxadd. - namespace, the relic — an IIFE on a global object, why ES modules superseded it, and where
declare namespacestill belongs. - Module augmentation — adding a property to a third-party library's types via
declare module, the module-file requirement, and the limits of merging. - Dynamic import() —
import()as a Promise expression and as a type, why a computed specifier yieldsany, and howmodule: commonjsbreaks code splitting. - Types for JS packages — where
@types/*comes from, the ordering of thetypescondition inexports, and whatskipLibCheckactually turns off.
Common Mistakes and Traps
| Mistake | Consequence |
|---|---|
Thinking module changes the syntax you write | module sets only what tsc emits; you keep writing import/export at any value |
Confusing module (output format) with target (JavaScript version) | Different axes: target downlevels the language syntax, module chooses the module format |
Confusing module (format) with moduleResolution (finding the file) | One decides how to emit, the other how to locate a file on disk; they are independent |
Leaving a file with no top-level import/export | It is a script: declarations leak into the global scope — «duplicate identifier» — fix it with export {} |
Enabling allowSyntheticDefaultImports alone and expecting run-time success | It is type-only and changes no emit; only esModuleInterop synthesises the default at run time |
Writing import * as express from 'express'; express() | Under esModuleInterop the namespace object is not callable — use a default import |
Leaving moduleResolution: node10 on a package with an exports map | exports is ignored and the wrong file is served; in TS 7.0 node10 is a hard error |
Configuring a paths alias only in tsconfig.json | tsc never rewrites the specifier — Node sees the literal @app/x and throws MODULE_NOT_FOUND |
Thinking namespace is erased like an interface | It emits a real IIFE and hangs properties off a global object |
Writing declare module 'lib' in a script file | Without a top-level import/export the block replaces the library's types instead of merging |
| Trying to change an existing member's type via augmentation | Declaration merging can only add members, never retype an existing one |
Expecting a computed import(\./${name}\) to be typed | There is no file to look at — the result is any, and the shape must be checked at run time |
Leaving module: commonjs with await import() | import() is downlevelled into require() — silently breaking code splitting and ESM-only packages |
Keeping a plain import for the sake of one type | The module is pulled into the bundle and can create a run-time cycle; write import type |
Installing @types/x for a package that ships its own declarations | It is redundant, and a stale @types shadows the library's real types |
Expecting tsc to report a circular import | Types resolve lazily, so the check stays silent; the run time crashes on an undefined at extends |
Interview relevance
The modules topic tests your execution model, not your vocabulary. The interviewer wants to hear that you hold two distinct questions in mind: what tsc emits (and what stays unchanged in the source) and what physically happens when Node or a bundler loads the resulting code. One question separates a junior from a middle — "how does module differ from moduleResolution?": the right answer is not "one is newer" but "one chooses the emit format, the other how a file is found on disk, and neither touches what you wrote".
Typical checks:
- The difference between
moduleandmoduleResolution, and that neither changes the source code. - The "module file versus script file" rule and why the
export {}idiom exists. - What
esModuleInteropdoes and how it differs fromallowSyntheticDefaultImports. - How
node16/nodenextdiffers frombundlerand whynode10is on the removal path. - What
import typeguarantees and why single-file transpilers need it underisolatedModules. - Why a
pathsalias type-checks yet throws at run time. - When
namespacestill belongs and why ES modules superseded it elsewhere. - How to augment a third-party library's types without forking it.
- Why a computed
import()yieldsanyand how to restore type safety. - Where
@types/*comes from and whatskipLibCheckactually turns off.
Common wrong answer: "import type is an optimisation, a hint to the bundler to drop the import during tree-shaking." In reality it is a guarantee about the emit: the statement is erased unconditionally, in the syntax. It matters because a single-file transpiler (esbuild, SWC, Babel) sees one file and, with no cross-file types, cannot tell an import { User } type from an import { User } value — whereas tsc can, because it sees the whole program. The same confusion blinds a candidate to circular imports: a plain value import kept for the sake of one type creates a run-time edge, and the module evaluated second receives the other's still-empty exports — class Order extends undefined throws exactly where tsc --noEmit was completely clean.