Modules

This section documents the current TypeScript release line as published at the official TypeScript documentation, which is the reference these pages are written and verified against. No specific patch version is pinned.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, since TypeScript iterates quickly.

This section’s bibliography lists the reference material consulted while preparing these pages.

TypeScript builds on the ES module system: any file with a top-level import or export is a module, and everything else is a script. This page covers module syntax, the type-only forms, the module / moduleResolution compiler options, path mapping, CommonJS interop, ambient module declarations, and the legacy namespace construct. Read the handbook’s Modules chapter alongside the deeper Modules — Theory and Modules — Reference guides; the older model is described in Namespaces and Modules.

For the runtime semantics of ES modules themselves, see Modules.

ES module syntax

// math.ts -- named exports
export const PI = 3.14159;
export function area(r: number): number {
  return PI * r * r;
}

// logger.ts -- a default export plus a named one
export default class Logger {
  log(msg: string): void { console.log(msg); }
}
export const LEVELS = ["debug", "info", "warn"] as const;

// app.ts -- consuming both styles
import Logger, { LEVELS } from "./logger.js";
import { PI, area } from "./math.js";
import * as math from "./math.js";        // namespace import: math.area(...)

new Logger().log(`PI=${PI}, area=${area(2)}, levels=${LEVELS.length}, ${math.PI}`);

Re-exports

// index.ts -- a barrel that forwards other modules' exports
export { area, PI } from "./math.js";
export { default as Logger } from "./logger.js";
export * from "./shapes.js";              // re-export all named exports
export * as shapes from "./shapes.js";    // re-export as a single namespace object

Dynamic import

import() is a function-like expression that returns a Promise of the module namespace. Use it for code-splitting or conditional loading; the specifier can be computed at runtime.

async function loadLocale(name: string): Promise<Record<string, string>> {
  const mod = await import(`./locales/${name}.js`);
  return mod.default;
}

// Types flow through: `mod` is typed as the target module's shape
const { area } = await import("./math.js");

Module vs. script mode

A file with no top-level import or export is a script: its declarations go into the global scope and can collide with other scripts. Add an empty export \{} to force module mode.

// util.ts -- without the next line, `helper` would be a global
export {};
function helper() { /* ... */ }

Top-level await

Allowed only in modules, and only when module is es2022, esnext, system, node16, nodenext, or preserve.

// config.ts
const res = await fetch("https://example.com/config.json");
export const config: unknown = await res.json();

Type-only imports and exports

import type / export type mark a binding as existing only in the type system; it is erased from the JavaScript output. This prevents accidental runtime dependencies and side-effect imports, and is required for correct output under single-file transpilers.

import type { User } from "./models.js";                 // whole import is type-only
import { createUser, type UserId } from "./models.js";   // inline: value + type in one statement
export type { User };
export { makeId, type UserId as Id };

// `import type` bindings cannot be used as values -- this would be an error:
// const u = new User();

verbatimModuleSyntax (the modern setting, replacing importsNotUsedAsValues and preserveValueImports) makes emit purely mechanical: any import / export without the type keyword is kept verbatim, any with it is dropped. You then must write import type for anything used only as a type.

// With verbatimModuleSyntax: true
import { type Shape, draw } from "./shapes.js"; // `Shape` erased, `draw` kept
import type { Config } from "./config.js";      // entire statement erased

isolatedModules tells TypeScript to check that each file can be transpiled on its own (by Babel, esbuild, swc). It bans const enum used across files, plain re-exports of types without the type keyword, and other whole-program tricks. Turn it on whenever a bundler — not tsc — produces your JavaScript. See tsconfig.json and Compiler Options.

module and moduleResolution

The module option controls the emitted module format; moduleResolution controls how import specifiers are found. Modern configs pick a matched pair.

{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2022",
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "verbatimModuleSyntax": true,
    "baseUrl": ".",
    "paths": {
      "@app/*": ["src/*"],
      "@config": ["src/config/index.ts"]
    }
  }
}
  • node16 / nodenext — Node’s dual ESM/CJS algorithm. The file’s format is decided by the nearest package.json "type" and the file extension; relative imports must carry an explicit extension (./x.js), and package.json "exports" / "imports" are honoured. nodenext tracks the latest Node behaviour, node16 pins it.

  • bundler — models what esbuild / Vite / webpack do: "exports" is respected but extensionless relative imports are allowed. Pair with "module": "esnext" or "preserve" and let the bundler emit.

  • classic — the pre-Node 2013-era walk-up algorithm with no node_modules and no "exports" support. Deprecated; never choose it for new code.

node10 (formerly node) is the legacy node_modules resolution without "exports" support — only for old CommonJS projects.

paths and baseUrl

paths maps specifier patterns to on-disk locations for the type checker only, resolved relative to baseUrl (or to the tsconfig under moduleResolution: bundler / node16 / nodenext, where baseUrl is no longer required).

tsc does not rewrite these specifiers at emit. import x from "@app/util" stays exactly that string in the .js output. Something else — a bundler, tsc-alias, Node’s subpath "imports", or the runtime’s own resolver — has to make that specifier resolvable at runtime. Prefer package "imports" (#app/*) when you want a mapping that works with no build step.

JSON imports

With resolveJsonModule, a .json file can be imported and is typed from its contents.

import pkg from "./package.json";      // pkg.version: string, statically typed
import { version } from "./package.json" with { type: "json" }; // import attributes, nodenext

Package exports / imports and .js specifiers

Under node16 / nodenext / bundler, TypeScript reads the consumed package’s "exports" map and its "types" condition to locate declarations, and its "imports" map for the package’s own #-prefixed internal specifiers.

{
  "name": "mylib",
  "type": "module",
  "exports": {
    ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
    "./util": { "types": "./dist/util.d.ts", "import": "./dist/util.js" }
  },
  "imports": {
    "#internal/*": "./dist/internal/*.js"
  }
}

In .ts source you write the specifier with the .js extension that will exist at runtime — TypeScript resolves it back to the sibling .ts / .d.ts for checking:

import { area } from "./math.js";            // file on disk is math.ts
import { helper } from "#internal/helper.js"; // via the package's own "imports" map

CommonJS <-> ESM interop

A CommonJS module sets module.exports = …​; an ES import expects a namespace object with named bindings and a default. Two flags bridge the gap.

  • esModuleInterop — synthesises a default for CJS modules so import express from "express" works, and makes import * as ns a true namespace object rather than the bare module.exports. Implies allowSyntheticDefaultImports.

  • allowSyntheticDefaultImports — type-checking only: lets you write a default import even when no synthetic default is emitted (for example when a bundler handles the interop at build time).

// With esModuleInterop: true
import express from "express";        // CJS `module.exports = express`, seen as the default
import * as fs from "node:fs";        // namespace of named exports

// Without esModuleInterop you were forced to write the TS-specific CJS import:
import express = require("express");  // still valid, and the correct form under node16 for CJS

Pitfall: if a package’s types declare export default but its runtime is CJS with module.exports = fn, a default import works only with esModuleInterop (or the import x = require(…​) form). Mismatched interop settings between a library and its consumer are a common cause of "X is not a function" at runtime.

Ambient module declarations

declare module "name" describes a module that has no .d.ts of its own, or augments an existing one. A wildcard specifier types whole classes of non-code imports handled by a bundler loader.

// globals.d.ts
declare module "untyped-legacy-lib" {
  export function connect(url: string): void;
  const version: string;
  export default version;
}

// Wildcard: every `*.css` import is a record of class-name strings
declare module "*.css" {
  const classes: { readonly [name: string]: string };
  export default classes;
}

declare module "*.svg" {
  const url: string;
  export default url;
}
import styles from "./Button.css";   // styles: { readonly [name: string]: string }
import logo from "./logo.svg";        // logo: string

Module augmentation reopens a real module to add members — see Declaration Files.

Namespaces and triple-slash directives

namespace (originally "internal modules") predates ES modules. It groups declarations under one global object, optionally split across files with a path reference directive.

// legacy: shapes.ts
namespace Shapes {
  export interface Circle { r: number; }
  export const area = (c: Circle) => Math.PI * c.r ** 2;
}

// consumer -- no import, `Shapes` is global
const a = Shapes.area({ r: 2 });

Prefer ES modules over namespaces. For application and library code, use import / export. Namespaces do not express file dependencies to the module loader, resist tree-shaking, and pollute the global scope. The handbook’s Namespaces and Modules page gives the same advice.

declare namespace is still idiomatic in hand-written .d.ts to model the shape of a global UMD library, paired with export as namespace.

// jquery-lib.d.ts (sketch)
declare namespace JQueryLib {
  interface Options { debug?: boolean; }
  function init(opts?: Options): void;
}
export = JQueryLib;
export as namespace JQueryLib;

Triple-slash directives are single-line comments containing an XML tag, valid only at the very top of a file. The ones still in use:

  • /// <reference types="node" /> — pull in an @types package’s global declarations from inside another .d.ts where you cannot use the tsconfig types array.

  • /// <reference lib="es2023.array" /> — depend on a specific slice of the built-in lib.

  • /// <reference path="./other.d.ts" /> — legacy file ordering for non-module .d.ts; avoid in module code.

  • /// <reference no-default-lib="true"/> — only when authoring a replacement lib.d.ts.

In ordinary import-using source you rarely need any of these; the tsconfig types / lib arrays and normal imports cover it.

Module resolution at a glance

flowchart TD A["import specifier in a .ts file"] --> B{"moduleResolution setting"} B -->|"classic (deprecated)"| C["Walk up parent folders,
no node_modules, no exports"] B -->|"node10 / node (legacy)"| D["node_modules walk,
ignores package exports"] B -->|"node16 / nodenext"| E{"Relative specifier?"} B -->|"bundler"| F["node_modules plus package exports,
extensionless imports allowed"] E -->|"yes"| G["Require explicit extension,
write .js for a .ts file"] E -->|"no"| H["Resolve via package.json
exports / imports plus types condition"] G --> I["Check against sibling .ts / .d.ts"] H --> I F --> I D --> I C --> I

Project References and Build covers how resolution interacts with composite builds and tsc --build.