TypeScript ES Modules

An ES module in TypeScript is simply a file that contains at least one top-level import or export statement. That one detail changes everything about how the file behaves: instead of dumping its declarations into the global scope like a classic script, the file gets its own private scope, and anything another file wants to use from it must be explicitly exported and imported. TypeScript builds directly on this ECMAScript standard and adds a type-checking layer on top: it verifies that what crosses a module boundary is used correctly on both sides, understands type-only imports that disappear at runtime, and knows how to resolve .ts, .tsx, and .d.ts files during that process.

This lesson covers ES modules as TypeScript extends them — from the basic export/import syntax to the parts that consistently trip people up: default vs. named exports, renaming exports, type-only imports, and what actually survives into the compiled JavaScript.

Overview: How ES Modules Work in TypeScript

Whether a .ts file is treated as a module or a script depends entirely on whether it contains a top-level import or export. A file with neither is compiled as a script: every const, function, and interface it declares is visible to every other script file in the program, exactly like classic pre-module JavaScript. The moment you add a single export, the file becomes a module: its top-level declarations are private by default, and the only things visible to the outside world are the ones you explicitly export.

TypeScript’s job at the module level is twofold. First, it performs module resolution: given an import specifier like \"./cart\" or \"lodash\", it has to find the corresponding declaration (a .ts file, a .d.ts file, or an @types package) so it knows the shape of what you’re importing. The resolution strategy is controlled by the moduleResolution compiler option — modern projects typically use \"bundler\" or \"node16\"/\"nodenext\", which mimic how Node.js and bundlers actually resolve specifiers, including how they handle file extensions and package.json \"exports\" maps. Second, TypeScript type-checks across the boundary: if cart.ts exports a function expecting a Product, and main.ts imports that function and calls it with the wrong shape of object, that’s a compile error, even though the two files are compiled independently at the JavaScript level.

Structural typing applies across modules exactly as it does within a single file — an imported type is just a type, and any object matching its shape satisfies it, regardless of where it was constructed. And critically, types are erased at compile time. An interface or type alias never appears in the emitted JavaScript at all; only actual runtime values (functions, classes, variables) survive. This is why TypeScript has a dedicated syntax for type-only imports — so the compiler (and tools like bundlers) can tell, before even looking at types, which imports are guaranteed to be erasable.

Syntax

The general shapes of ES module syntax, as used in a TypeScript file:

// Named export
export const value = 1;
export function doThing() { }

// Default export (one per module)
export default class Thing { }

// Named import
import { value, doThing } from \"./utils\";

// Default import
import Thing from \"./thing\";

// Renaming on import or export
import { value as val } from \"./utils\";
export { doThing as run };

// Namespace import (everything as one object)
import * as Utils from \"./utils\";

// Type-only import/export (erased entirely)
import type { Config } from \"./config\";
export type { Config };

// Re-export from another module
export * from \"./utils\";

// Side-effect only import (no bindings)
import \"./polyfills\";

// Dynamic, lazily-loaded import (returns a Promise)
const mod = await import(\"./utils\");
Form What it does
export const/function/class Named export — many per module, imported with matching names in braces
export default ... Default export — at most one per module, imported without braces under any local name
import { a, b } from \"...\" Import specific named bindings
import X from \"...\" Import the default export, bound to local name X
import * as X from \"...\" Namespace import — all named exports collected as properties of X
import type { T } Import used only as a type; guaranteed erased from output
export * from \"...\" Re-export everything from another module (a \”barrel\” pattern)
import(\"...\") Dynamic import — loads a module at runtime, resolves to a Promise

Examples

Example 1: A module with named and default exports

export interface Product {
  id: number;
  name: string;
  price: number;
}

export function formatPrice(product: Product): string {
  return `$${product.price.toFixed(2)}`;
}

export const TAX_RATE = 0.08;

export default class Cart {
  private items: Product[] = [];

  add(product: Product): void {
    this.items.push(product);
  }

  total(): number {
    const subtotal = this.items.reduce((sum, p) => sum + p.price, 0);
    return subtotal * (1 + TAX_RATE);
  }
}

const cart = new Cart();
cart.add({ id: 1, name: \"Keyboard\", price: 49.99 });
cart.add({ id: 2, name: \"Mouse\", price: 19.99 });
console.log(cart.total().toFixed(2));

Output:

75.58

This single file is a complete module: it has one default export (the Cart class — a module may have only one of these) and several named exports (the Product interface, the formatPrice function, and the TAX_RATE constant). Note that Product, a type, and TAX_RATE, a value, are exported with exactly the same keyword — TypeScript figures out from context whether each name is a type or a value.

Example 2: Importing named, default, and type-only bindings

// cart.ts
export interface Product {
  id: number;
  name: string;
  price: number;
}

export function formatPrice(product: Product): string {
  return `$${product.price.toFixed(2)}`;
}

export const TAX_RATE = 0.08;

export default class Cart {
  private items: Product[] = [];

  add(product: Product): void {
    this.items.push(product);
  }

  total(): number {
    const subtotal = this.items.reduce((sum, p) => sum + p.price, 0);
    return subtotal * (1 + TAX_RATE);
  }
}

// main.ts
import Cart, { formatPrice, TAX_RATE, type Product } from \"./cart\";

const laptop: Product = { id: 3, name: \"Laptop\", price: 999.99 };
const cart = new Cart();
cart.add(laptop);

console.log(formatPrice(laptop));
console.log(`Tax rate: ${TAX_RATE * 100}%`);
console.log(cart.total().toFixed(2));

Output:

$999.99
Tax rate: 8%
1079.99

This is the pattern you’ll write constantly: main.ts pulls in the default export (Cart, given a fresh local name), two named values (formatPrice, TAX_RATE), and the Product type — all in one import statement. The inline type modifier before Product tells the compiler that this specific binding is type-only, so it can be stripped even if the rest of the statement imports real values.

Example 3: Renaming exports

function celsiusToFahrenheit(celsius: number): number {
  return (celsius * 9) / 5 + 32;
}

function fahrenheitToCelsius(fahrenheit: number): number {
  return ((fahrenheit - 32) * 5) / 9;
}

export { celsiusToFahrenheit as toFahrenheit, fahrenheitToCelsius as toCelsius };

console.log(celsiusToFahrenheit(100));
console.log(fahrenheitToCelsius(212));

Output:

212
100

The export { name as alias } form lets you keep concise local names inside the module while presenting a different (often more descriptive) name to importers. The rename only affects the external name — inside this file you must still call the functions by their original local names, as the console.log calls do here.

How It Works Step by Step (Under the Hood)

  • Parsing: the compiler scans each file; if it finds a top-level import or export, the file is flagged as a module rather than a global script.
  • Resolution: for every import specifier, TypeScript locates the matching file (or ambient/@types declaration) using the configured moduleResolution strategy, then reads its exported declarations.
  • Type checking: each imported binding is checked against how it’s used in the importing file — call signatures, property access, and assignability are all checked exactly as within one file.
  • Erasure: everything that only exists at the type level — interface, type, import type/export type declarations — is deleted entirely during emit. The compiled JavaScript contains only runtime constructs: functions, classes, variables, and the plain import/export statements referring to them.
  • Module emit: depending on the module compiler option, the remaining import/export syntax is either preserved as-is (for \"esnext\"/\"es2020\", e.g. for bundlers) or rewritten into require/module.exports calls (for \"commonjs\", e.g. for plain Node without ESM). This is also where the esModuleInterop flag matters: it makes a CommonJS module’s whole export object act as if it were that module’s default export, so import X from \"cjs-package\" works even against packages that were never written as ES modules.
  • Dynamic import(): unlike static import, a call to import(\"./module\") is an expression, evaluated at runtime, returning a Promise that resolves to the module’s exports. TypeScript still type-checks the result using the target module’s declared types. This is how you lazy-load code — only fetching or evaluating a module when it’s actually needed, such as behind a feature flag or a route change.

Common Mistakes

Mistake 1: Trying to have two default exports

A module can only have one default export. This code fails to compile:

export default function add(a: number, b: number): number {
  return a + b;
}

export default function subtract(a: number, b: number): number {
  return a - b;
}

tsc reports: TS2528: A module cannot have multiple default exports. Fix it by making at most one of them the default and exporting the rest by name:

export function add(a: number, b: number): number {
  return a + b;
}

export default function subtract(a: number, b: number): number {
  return a - b;
}

console.log(add(2, 3));
console.log(subtract(5, 2));

Output:

5
3

Mistake 2: Forgetting that types don’t exist at runtime

Because types are erased, you can’t reference a type alias as if it were a value:

type Point = { x: number; y: number };

console.log(Point);

tsc reports: TS2693: ‘Point’ only refers to a type, but is being used as a value here. Point is purely a compile-time construct — there is nothing left of it to log at runtime. Use it to type an actual value instead:

type Point = { x: number; y: number };

const origin: Point = { x: 0, y: 0 };
console.log(origin);

Output:

{ x: 0, y: 0 }

Best Practices

  • Prefer named exports for most things — they’re easier to refactor (renaming is tracked everywhere) and force explicit import names, which helps readability and autocomplete.
  • Reserve export default for a module’s one clear \”main thing\” (a component, a single class), not for arbitrary utility functions.
  • Use import type / export type (or the inline type modifier) for anything that’s purely a type — it documents intent and guarantees the import is erased, which matters under isolatedModules (used by single-file transpilers like esbuild or Babel that can’t see across files to know a name is type-only).
  • Avoid deep export * from barrel chains in large codebases — they can slow down builds and make it hard to trace where a symbol actually lives; prefer them only for small, stable public APIs.
  • Reach for dynamic import() when a module is large, rarely needed, or should only load in response to user action — not for ordinary dependencies your module always needs.
  • Set esModuleInterop (and let moduleResolution match your actual runtime/bundler) so default imports from CommonJS packages behave predictably.

Practice Exercises

  • Write a module shapes.ts that named-exports an interface Shape (with a kind and dimensions) and a function area(shape: Shape): number, then default-exports a class ShapeCollection with an add method and a totalArea() method. In a second file, import all of it and log the total area for two or three shapes.
  • Take the Cart example from this lesson and add a named export type CartSummary = { itemCount: number; total: number }. Import it into another file using import type, and write a function there that accepts a CartSummary and formats it as a string.
  • Deliberately write a module with two export default statements, run tsc, and read the exact error it reports. Then fix it by renaming one to a named export.

Summary

  • A file becomes an ES module the moment it has a top-level import or export; otherwise it’s compiled as a global script.
  • A module may have exactly one default export but any number of named exports; both can be renamed with as.
  • TypeScript type-checks across module boundaries using the same structural typing rules as within a single file.
  • import type/export type (or the inline type modifier) mark bindings that are erased entirely at compile time — types never exist in the emitted JavaScript.
  • Dynamic import(\"...\") loads a module at runtime and returns a Promise, useful for lazy-loading.
  • The module and moduleResolution compiler options control how import/export syntax is emitted and how specifiers are resolved — and esModuleInterop smooths over interop with CommonJS packages.