Capítulo 32 de 36

Chapter 32: Module Syntax Extras & the module Compiler Option

Core Idea

Beyond standard ES import/export, TypeScript adds a handful of type-system-specific syntax extensions for modules (type-only imports, import() types, export =, ambient modules) — and the module compiler option controls what the emitted module format actually looks like, with node16/node18/nodenext being the only options that correctly model Node.js's dual CommonJS/ESM system.

Key Concepts

  • Type declarations export/import like values: export type SomeType = ... / export interface X {} work with the same export/named-export syntax as functions and variables, and import the same way. Through a namespace import (import * as mod from "./m.js"), an exported type is usable only in type positions (let x: mod.SomeType), not as a value.
  • Type-only imports/exports: TypeScript elides (drops from emitted JS) any import/export that's only ever used in a type position, by default, automatically. import type { X }, export type { X }, and per-specifier type prefixes (import { f, type X } from "...") make that elision explicit and guaranteed rather than inferred — useful for non-type-aware transpilers (Babel/swc/esbuild) that need to know up front what's safe to strip. A type-only import can't mix a default import and named bindings in one statement (ambiguous whether type applies to just the default or the whole statement) — split into two statements, or alias default as a named binding instead.
  • import("module").Type (inline import types): references a type from another module without a full import declaration — type Options = import("fs").WriteFileOptions. The main use case is JSDoc in plain .js files, where a real import statement isn't otherwise available for pulling in types (/** @type {import("webpack").Configuration} */).
  • export = / import x = require(...): a TypeScript-specific analog of CommonJS's module.exports = ... / const x = require(...), used because plain JS assignment syntax (module.exports = SomeInterface) can't reference a type the way export = SomeInterface can — this syntax predates modern import type/type-only patterns and remains the way to CommonJS-export something that's a type, not a value.
  • Ambient modules (declare module "name" { ... }, in a non-module script file): describe a module that exists at runtime but has no corresponding source file TypeScript can see — the standard way to type a runtime-provided module like Node's "fs", or (with a * wildcard in the name) a whole class of loader-provided modules like "*.html". Critical distinction from module augmentation: identical syntax, but if the containing file itself has any top-level import/export (making it a module, not a script), the same declare module "name" {...} block becomes an augmentation of an existing declaration instead of a fresh ambient one — accidentally adding an unrelated export {} to a file can silently flip this meaning.
  • The module compiler option controls the emitted output format. In practice there are three tiers to choose from:
    • node16 / node18 / node20 / nodenext — the only options that correctly model Node.js's real dual-format system: each input file's format (ESM or CJS) is detected per-file (from its extension, or the nearest package.json's "type" field), and the emitted format matches, rather than forcing everything into one format. These are the only correct choices for anything actually running under Node.js, whether or not the project uses ES modules.
    • esnext/es2015es2022 — forces ESM-shaped output for every file regardless of what it depends on; the right choice for bundlers, Bun, or tools like tsx (paired with moduleResolution: bundler), never for direct Node.js execution.
    • commonjs/amd/umd/system — force a single legacy output format; generally superseded by letting a bundler or node16+ handle format concerns, kept mainly for compatibility with older tooling.
    • preserve (5.4+) — keeps each import/export statement in whatever form it was written (ESM stays ESM, require/export = stay CommonJS-shaped) instead of coercing the whole file to one format — the best match for modern bundlers and the Bun runtime, which handle mixed syntax natively.
  • Why module still matters even with noEmit (bundler/Bun workflows): even when TypeScript isn't the one producing the final JS, its type-checking and module-resolution behavior are still shaped by what it would emit — setting module correctly (preserve or esnext + bundler resolution) keeps the types TypeScript shows for an import accurate to what the bundler/runtime will actually do.

Code Examples

import { f, type Options } from "./module.js"; // f stays; Options is guaranteed erased
class C {
  constructor(o: Options) { f(); }
}
  • What it demonstrates: an inline type specifier guaranteeing a specific named import is erased from emitted JS, alongside an ordinary value import in the same statement.
// a.ts
interface Config { debug: boolean }
export = Config; // OK — export = can reference a type; `module.exports = Config` cannot
  • What it demonstrates: export = handling a case plain JS assignment syntax can't express — exporting something that's purely a type.

Reference Tables

module valueEmitted formatUse for
node16/node18/node20/nodenextESM or CJS, per-file detectedany project actually running in Node.js
esnext/es2015es2022always ESMbundlers, Bun, tsx (with moduleResolution: bundler)
preservewhatever was writtenmodern bundlers, Bun — mixed ESM/CJS syntax in one file
commonjs/amd/umd/systemalways that one legacy formatlegacy tooling only — prefer a bundler otherwise

Key Takeaways

  1. Use node16/node18/nodenext for anything that actually runs in Node.js — every other module value is format-agnostic and can silently produce output incompatible with Node's real dual-module rules.
  2. Reach for import type/inline type specifiers when a build pipeline includes a non-type-aware transpiler that needs certainty about what's erasable.
  3. export = exists specifically to let a CommonJS-style export reference a type — plain module.exports = SomeType can't.
  4. Don't confuse an ambient module declaration with a module augmentation — the same declare module "x" {} syntax means something different depending on whether its containing file is itself a module.

Connects To

  • Modules: the foundational ES Module syntax this chapter extends.
  • Declaration Merging: module augmentation, the easy-to-confuse sibling of ambient module declarations.
  • Type Declarations: .d.ts ambient declarations, where most of this syntax actually lives in practice.