Modules — import / export
TypeScript builds directly on top of JavaScript's ES module system (ESM), adding static type checking to every import and export. Understanding modules is essential: they are how you split code into reusable files, control what is public API, and ensure that TypeScript can verify cross-file references at compile time.
This page covers everything from basic named exports to re-exports, default exports, dynamic imports, and the subtle differences between CommonJS and ESM in a TypeScript project.
What Is a Module?
In TypeScript (and JavaScript), any file that contains a top-level import or
export statement is a module. Files without either are scripts — their
declarations leak into the global scope, which causes collisions in larger projects.
Always aim to write modules, not scripts. If a file has no imports/exports yet,
add export {} at the top to make TypeScript treat it as a module.
// script.ts — declarations are GLOBAL (avoid this)
const version = '1.0'; // pollutes global scope
// module.ts — declarations are scoped to this file
export const version = '1.0'; // private unless exported
export {} to any file that has no other exports to force TypeScript to treat it as a module and avoid accidental global pollution.Named Exports
Named exports let you export multiple values from a single file. The consumer
imports them by the exact name (or renames with as).
// math.ts
export const PI = 3.14159;
export function add(a: number, b: number): number {
return a + b;
}
export function multiply(a: number, b: number): number {
return a * b;
}
export type Vector2 = { x: number; y: number };
export interface Shape {
area(): number;
}
// main.ts — importing named exports
import { PI, add, multiply, Vector2, Shape } from './math';
const result = add(2, 3); // 5
const vec: Vector2 = { x: 1, y: 2 };
// Rename on import with 'as'
import { add as sum, PI as π } from './math';
const total = sum(10, 20); // 30
Default Exports
A module can have exactly one default export. Default exports are imported without curly braces and can be named anything at the import site.
// logger.ts
export default class Logger {
constructor(private prefix: string) {}
log(message: string) {
console.log(`[${this.prefix}] ${message}`);
}
}
// app.ts
import Logger from './logger'; // any name works
import AppLogger from './logger'; // also valid — same export
const log = new Logger('APP');
log.log('Started');
Exporting Types vs Values
TypeScript lets you export both runtime values (functions, classes, constants) and
compile-time types (interfaces, type aliases). Both use the same export keyword,
but types are erased at compile time:
// user.ts
export interface User {
id: string;
name: string;
email: string;
}
export type UserRole = 'admin' | 'editor' | 'viewer';
export class UserService {
getUser(id: string): User {
return { id, name: 'Alice', email: 'alice@example.com' };
}
}
// Exported but erased at runtime (JavaScript output has no trace of User or UserRole)
Re-exports
Re-exports let you compose a public API by forwarding exports from other modules. This is the basis of barrel files (covered in the Path Aliases page).
// shapes/circle.ts
export class Circle {
constructor(public radius: number) {}
area() { return Math.PI * this.radius ** 2; }
}
// shapes/rectangle.ts
export class Rectangle {
constructor(public width: number, public height: number) {}
area() { return this.width * this.height; }
}
// shapes/index.ts — re-export everything
export { Circle } from './circle';
export { Rectangle } from './rectangle';
// Rename on re-export
export { Circle as Disc } from './circle';
// Re-export everything from a module (use sparingly)
export * from './circle';
export * from './rectangle';
// Re-export with a namespace
export * as Shapes from './circle';
// consumer.ts — single import point
import { Circle, Rectangle } from './shapes';
const c = new Circle(5);
const r = new Rectangle(4, 6);
Namespace Imports
When a module exports many names, you can import them all under a single namespace
object using * as:
// utils.ts
export function clamp(n: number, min: number, max: number) {
return Math.min(Math.max(n, min), max);
}
export function lerp(a: number, b: number, t: number) {
return a + (b - a) * t;
}
export const EPSILON = 1e-9;
// consumer.ts
import * as Utils from './utils';
const clamped = Utils.clamp(1.5, 0, 1); // 1
const mixed = Utils.lerp(0, 100, 0.25); // 25
Dynamic Imports
Static import statements are resolved at compile time and bundled eagerly.
Dynamic import() is an async expression that loads a module on demand — perfect
for code splitting and lazy loading.
// Lazy-load a heavy library only when needed
async function generatePDF(data: unknown) {
// The module is fetched only when this function is called
const { PDFDocument } = await import('./pdf-utils');
return PDFDocument.create(data);
}
// TypeScript fully types the dynamic import
async function loadChartLibrary() {
const chartLib = await import('./chart-lib');
// chartLib is typed as the module's exports
chartLib.render('#chart', { type: 'bar', data: [] });
}
// Conditional loading based on environment
async function loadPolyfill() {
if (!window.fetch) {
await import('whatwg-fetch'); // side-effect only import
}
}
import() always returns a Promise. The resolved value is the module namespace object with all the module's exports.CommonJS vs ESM in TypeScript
TypeScript can emit either CommonJS (require/module.exports) or ESM
(import/export). The key setting is module in your tsconfig.json.
tsconfig module | Emitted syntax | Use case |
|---|---|---|
commonjs | require / module.exports | Node.js (traditional) |
es2020 / esnext | import / export | Modern Node.js, browsers, bundlers |
node16 / nodenext | Hybrid (CJS + ESM) | Node.js with .mjs/.cjs files |
preserve | Keep source syntax | Bundlers (Vite, esbuild, webpack) |
// tsconfig.json (modern Node.js project)
{
"compilerOptions": {
"module": "node16", // or "nodenext"
"moduleResolution": "node16",
"target": "es2022",
"outDir": "./dist"
}
}
Interop: Importing CommonJS from ESM
Many npm packages still ship CommonJS. TypeScript provides interop settings so you
can use import syntax with CJS modules:
// tsconfig.json
{
"compilerOptions": {
"esModuleInterop": true, // enables default import from CJS modules
"allowSyntheticDefaultImports": true // implied by esModuleInterop
}
}
// Without esModuleInterop you'd need:
import * as fs from 'fs';
const data = fs.readFileSync('./file.txt');
// With esModuleInterop you can write:
import fs from 'fs';
const data = fs.readFileSync('./file.txt');
esModuleInterop changes how TypeScript emits import helpers. It is enabled by default in most modern project setups (Next.js, Vite, etc.) but be consistent — mixing interop styles across a codebase causes subtle bugs.Side-Effect Imports
// A module imported purely for its side effects (no bindings used)
import './setup-globals'; // registers global polyfills
import 'reflect-metadata'; // required by decorators
import './styles/global.css'; // CSS side-effect in bundler contexts
Circular Imports
Circular imports (A imports B, B imports A) are legal in ES modules but can cause runtime issues if values are accessed before they are initialized. TypeScript does not warn about circles — it's your responsibility to avoid them.
// ❌ Circular reference — can cause 'undefined' at runtime
// a.ts
import { b } from './b';
export const a = b + '-a';
// b.ts
import { a } from './a';
export const b = a + '-b'; // 'a' may be undefined here!
// ✓ Fix: extract shared logic to a third module
// shared.ts
export const base = 'shared';
// a.ts
import { base } from './shared';
export const a = base + '-a';
// b.ts
import { base } from './shared';
export const b = base + '-b';
Module Augmentation
You can extend the types of an existing module without modifying it — a pattern called module augmentation. This is commonly used to add properties to third-party types or global interfaces.
// extend-express.d.ts — add a custom property to Express Request
import 'express';
declare module 'express' {
interface Request {
user?: { id: string; email: string };
}
}
// Now TypeScript knows about req.user in every Express route
import express from 'express';
const app = express();
app.get('/profile', (req, res) => {
if (req.user) {
res.json({ email: req.user.email }); // fully typed ✓
}
});
Exporting Interfaces for Plugin Systems
// plugin-api.ts — define the contract every plugin must satisfy
export interface Plugin {
name: string;
version: string;
install(app: App): void;
uninstall?(app: App): void;
}
export interface App {
use(plugin: Plugin): void;
emit(event: string, data: unknown): void;
}
// my-plugin.ts — implement the contract
import type { Plugin, App } from './plugin-api';
export const myPlugin: Plugin = {
name: 'my-plugin',
version: '1.0.0',
install(app: App) {
app.emit('plugin:installed', { name: 'my-plugin' });
},
};
Best Practices
Prefer named exports over default exports for better refactoring support.
Keep modules focused — one concept per file makes the graph easier to reason about.
Use re-exports in index files to expose a clean public API without leaking internals.
Use import type for type-only imports to eliminate them at compile time (see the Type-Only Imports page).
Avoid circular dependencies — they cause hard-to-debug initialization order bugs.
Enable esModuleInterop in tsconfig for seamless interop with CommonJS packages.
Use dynamic import() to split large bundles and load code on demand.
Summary
Syntax | What it does |
|---|---|
export const x = 1 | Named export |
export default fn | Default export (one per file) |
import { x } from ... | Named import |
import x from ... | Default import |
import * as ns from ... | Namespace import |
export { x } from ... | Re-export |
export * from ... | Re-export all named |
import(...) | Dynamic (lazy) import |
import type { T } | Type-only import (erased at emit) |