Modules & Resolution
ES modules versus CommonJS emit, module resolution strategies (node16, bundler), import type and isolatedModules, namespaces, module augmentation, dynamic import, and @types packages.
13 questions
JuniorTheoryVery commonWhere do the @types/* packages come from, and how does TypeScript find them?
Where do the @types/* packages come from, and how does TypeScript find them?
When a JavaScript package ships no types, the community publishes them separately as @types/<name>, built from the DefinitelyTyped repository. TypeScript picks them up automatically from node_modules/@types — you never import them. A package that ships its own declarations needs no @types at all.
Common mistakes
- ✗Installing
@types/xfor a library that already ships its own declarations - ✗Thinking you must
importfrom@types/*for it to take effect - ✗Assuming the
@typesversion tracks the library version automatically
Follow-up questions
- →What does the
typesarray intsconfig.jsonchange about this automatic pickup? - →How do you use a library that has neither its own types nor an
@typespackage?
MiddleTheoryVery commonWhy is CommonJS module.exports = x not the same as ESM export default x?
Why is CommonJS module.exports = x not the same as ESM export default x?
module.exports = x replaces the module object — the module is x. export default x adds one named export called default to a namespace object holding every other export too. So require() on an ES module hands you { default: x }, and default-importing CommonJS works only through an interop helper.
Common mistakes
- ✗Believing
export defaultcompiles to a baremodule.exports =assignment - ✗Forgetting that
require()of an ES module yields{ default: x }, notx - ✗Using
export defaultwhere the package must remain callable asrequire('m')()
Follow-up questions
- →Why can a module that uses
export =not also have named ES exports? - →How does
node16decide whether a given file is CommonJS or an ES module?
JuniorTheoryCommonWhat does the module compiler option control, and what does it not?
What does the module compiler option control, and what does it not?
module decides the module syntax tsc emits — commonjs rewrites import/export into require and exports.x, while esnext leaves them as ES modules. It never changes what you author. Under node16 the format is chosen per file, from the nearest package.json type field.
Common mistakes
- ✗Thinking
modulechanges the syntax you write rather than the syntaxtscemits - ✗Confusing
module(output format) withtarget(output JavaScript version) - ✗Assuming one
modulevalue pins the format for every file undernode16
Follow-up questions
- →Why does
nodenextrequire an explicit file extension on relative imports? - →What does
export =declare, and how must a consumer import it?
JuniorTheoryCommonWhat is a namespace in TypeScript, and why have ES modules superseded it?
What is a namespace in TypeScript, and why have ES modules superseded it?
namespace was TypeScript's pre-ESM way to group code: it emits an IIFE that hangs properties off a global object, and same-named blocks merge across files. ES modules replaced it because a module is scoped to its file, its imports are statically analysable, and bundlers can tree-shake it. Keep namespace for .d.ts files only.
Common mistakes
- ✗Believing
namespaceis erased like aninterface— it emits a real IIFE and a global object - ✗Using
namespaceto organise application code that already lives in ES modules - ✗Forgetting that same-named
namespaceblocks merge across files, so nothing is really encapsulated
Follow-up questions
- →When is
declare namespaceinside a.d.tsfile still the right tool? - →What does
declaration mergingbetween anamespaceand a function let you express?
MiddleTheoryCommonHow does esModuleInterop differ from allowSyntheticDefaultImports?
How does esModuleInterop differ from allowSyntheticDefaultImports?
allowSyntheticDefaultImports is type-only: it silences the no default export error so import fs from 'fs' type-checks, and emits nothing. esModuleInterop changes the emit — it wraps CommonJS requires in __importDefault so the import really receives module.exports — and it implies the first flag. Enable only the type-only one and the code compiles, then fails at run time.
Common mistakes
- ✗Enabling
allowSyntheticDefaultImportsalone and expecting the run-time import to work - ✗Believing the two flags are interchangeable names for the same behaviour
- ✗Missing that
esModuleInteropimpliesallowSyntheticDefaultImports, not the other way round
Follow-up questions
- →What do the
__importDefaultand__importStarhelpers actually do at run time? - →Why does
import * as express from 'express'become uncallable onceesModuleInteropis on?
MiddleTheoryCommonWhy does import type matter to a bundler when tsc erases type imports anyway?
Why does import type matter to a bundler when tsc erases type imports anyway?
tsc sees the whole program, so it knows a binding is used only in type position and elides the import. A single-file transpiler — esbuild, SWC, Babel — sees one file and no cross-file types, so it cannot tell whether import { User } names a type or a value. import type puts the answer in the syntax.
Common mistakes
- ✗Assuming every transpiler can elide type-only imports the way
tscdoes - ✗Thinking
import typeis a performance hint rather than a guarantee about the emit - ✗Leaving a value import of a module used only for its types, pulling it into the bundle
Follow-up questions
- →What does
verbatimModuleSyntaxchange about how imports are emitted? - →How can a plain type import introduce a circular dependency at run time?
MiddleTheoryCommonHow do moduleResolution values node10, node16 and bundler differ?
How do moduleResolution values node10, node16 and bundler differ?
node10 is the legacy CommonJS algorithm: walk up node_modules, guess the extension, ignore the exports map. node16/nodenext model what Node really does — honour exports, decide CommonJS or ESM per file, require an explicit extension on relative ESM imports. bundler honours exports but keeps extensionless imports legal.
Common mistakes
- ✗Leaving
moduleResolution: node10on a package that publishes anexportsmap - ✗Using
bundlerfor code that Node runs directly, where extensionless imports fail - ✗Confusing
moduleResolution(finding the file) withmodule(the emitted format)
Follow-up questions
- →Why is
moduleResolution: node10on the removal path, and what replaces it? - →How does the
exportsmap let a package expose different files to ESM and CommonJS consumers?
MiddleTheoryCommonWhy can a bundler tree-shake ES modules but not CommonJS?
Why can a bundler tree-shake ES modules but not CommonJS?
ESM import/export are static declarations: what a module takes and gives is known from the syntax alone, so a bundler builds the graph without running anything and drops unreferenced exports. require() is an ordinary call and module.exports is mutable, so nothing can be proven unused and it all stays.
Common mistakes
- ✗Thinking tree-shaking is about lazy loading rather than static analysability
- ✗Publishing only a CommonJS build and expecting consumers to shake it
- ✗Assuming
sideEffects: falsecan rescue a format the bundler cannot analyse
Follow-up questions
- →How does a barrel
index.tsthat re-exports everything defeat tree-shaking in practice? - →What exactly does
sideEffects: falseinpackage.jsonpermit a bundler to do?
MiddleTheoryOccasionalHow do you add a property to a third-party library's types without forking it?
How do you add a property to a third-party library's types without forking it?
Module augmentation: declare module 'lib' { ... } inside a file that is itself a module — it must have a top-level import or export. Your interfaces then merge with the library's own; merging can add members, never retype an existing one. Without that import or export the file is a script, and the block replaces the library's types.
Common mistakes
- ✗Writing
declare module 'lib'in a file with no import or export, which replaces the types instead of merging - ✗Trying to change the type of an existing member — merging can only add
- ✗Reaching for
declare globalwhen the target is a module, not a global
Follow-up questions
- →How would you augment Express's
Requestwith auserproperty added by middleware? - →Why must the augmenting file be imported somewhere for the augmentation to apply?
MiddleCodeOccasionalTyping a dynamic import() whose specifier is only known at run time
Typing a dynamic import() whose specifier is only known at run time
With a literal specifier import('./m.js') is typed — the compiler resolves the file and infers Promise<typeof import('./m.js')>. With a computed specifier it can resolve nothing, so the result is any and everything after it is unchecked. Await into unknown, narrow with a type guard that inspects the shape at run time, and throw when it fails.
Common mistakes
- ✗Assuming a template-literal specifier still resolves to a typed module
- ✗Asserting the imported module to
Pluginwith no run-time check of its shape - ✗Letting the
anyfrom a computedimport()leak out through the return type
Follow-up questions
- →What exactly does
typeof import('./m.js')denote, and when can you write it by hand? - →How would you keep the plugin registry type-safe if plugins are also allowed to register themselves?
SeniorDebuggingOccasionaltsc --noEmit is clean but the app crashes on startup with a circular import
tsc --noEmit is clean but the app crashes on startup with a circular import
Types resolve lazily, so a circular type reference is legal and the check stays silent. The value cycle in the emit is invisible to it: the module evaluated second gets the other's empty exports, so entity_1.Entity is undefined at extends. Erase the type-only edges with import type, and move shared values to a third module.
Common mistakes
- ✗Expecting
tscto report a cycle it is legally allowed to resolve lazily - ✗Reordering imports in the entry file, which only moves which module gets the empty
exports - ✗Dumping every shared type into one
types.tsinstead of erasing the type-only edges
Follow-up questions
- →Why does
verbatimModuleSyntaxmake this class of bug appear earlier rather than later? - →When is a value cycle genuinely unavoidable, and how do you make it safe?
SeniorTheoryOccasionalWhat does isolatedModules forbid, and which tool is it protecting you from?
What does isolatedModules forbid, and which tool is it protecting you from?
It makes tsc reject anything a single-file transpiler cannot compile correctly. esbuild, SWC and Babel see one file at a time with no cross-file types, so a re-exported type must be written export type { T } and a const enum cannot be inlined across files. It is a compatibility contract.
Common mistakes
- ✗Reading
isolatedModulesas a performance flag rather than a compatibility contract - ✗Believing it changes how
tscitself emits, when it only restricts what you may write - ✗Expecting the transpiler to type-check because
tscaccepted the file
Follow-up questions
- →Why is an ambient
const enumunusable underisolatedModules? - →If the transpiler never type-checks, what is left to run
tscfor?
SeniorTheoryOccasionalA paths alias type-checks but throws at run time. What is missing?
A paths alias type-checks but throws at run time. What is missing?
paths is a compile-time alias only. It tells the checker where @app/x resolves, but tsc leaves the specifier untouched in the emit — Node then sees a literal @app/x and throws MODULE_NOT_FOUND. You need a matching run-time resolver: a bundler alias, Node subpath imports, or a loader such as tsconfig-paths.
Common mistakes
- ✗Assuming
tscrewrites aliased specifiers in the emitted JavaScript - ✗Configuring the alias in
tsconfig.jsononly and never in the bundler or loader - ✗Letting the two alias maps drift apart, so one build resolves and another does not
Follow-up questions
- →Why are Node subpath imports (
#app/*) a stronger choice than atsconfig-pathsloader? - →What breaks for a consumer if you publish a package whose emitted code still contains
pathsaliases?