TypeScriptModules (import / export)

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.

TS
// 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
      
Note
Add 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).

TS
// 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;
}
      

TS
// 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.

TS
// logger.ts
export default class Logger {
  constructor(private prefix: string) {}

  log(message: string) {
    console.log(`[${this.prefix}] ${message}`);
  }
}
      

TS
// 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');
      
Tip
Prefer named exports over default exports in most cases. Named exports are easier to refactor (IDEs rename them automatically), and they make the export explicit in the consuming file.
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:

TS
// 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).

TS
// 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';
      

TS
// 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:

TS
// 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.

TS
// 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
  }
}
      
Note
Dynamic 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)

TS
// 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:

TS
// 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');
      
Warning
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

TS
// 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.

TS
// ❌ 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.

TS
// 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

TS
// 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)