ES Modules in TypeScript

Difficulty: Advanced

TypeScript fully embraces the ES module system, providing first-class support for import and export syntax with additional type-aware features. Every file containing a top-level import or export is treated as a module, while files without these statements are treated as scripts whose declarations are available in the global scope. Understanding how modules work in TypeScript is fundamental to building well-structured, maintainable applications.

Named exports allow you to export multiple values from a module, each accessible by its exact name. Default exports provide a single primary export per module. You can also combine both in the same file. Re-exports let you aggregate and re-expose exports from other modules, which is the foundation of the barrel file pattern commonly used to simplify import paths.

TypeScript 3.8 introduced type-only imports and exports using the `import type` and `export type` syntax. These are completely erased at compile time and generate no JavaScript output. This is crucial for avoiding circular dependency issues and ensuring that imports used only for type checking do not bloat your runtime bundle. When importing interfaces, type aliases, or other purely type-level constructs, prefer type-only imports.

The barrel file pattern involves creating an index.ts file that re-exports from multiple modules in a directory. This allows consumers to import everything from a single entry point rather than reaching into individual files. While convenient, barrel files can cause issues with tree-shaking and circular dependencies if not managed carefully. Use them judiciously, especially in large codebases.

Code examples

Named and Default Exports

// math.ts
export function add(a: number, b: number): number {
  return a + b;
}

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

export default function multiply(a: number, b: number): number {
  return a * b;
}

// app.ts
import multiply, { add, subtract } from './math';

console.log(add(5, 3));
console.log(subtract(10, 4));
console.log(multiply(3, 7));

Named exports are imported with curly braces, while the default export can be imported with any name without braces. You can import both in the same statement.

Type-Only Imports and Re-exports

// types.ts
export interface User {
  id: number;
  name: string;
  email: string;
}

export type Role = 'admin' | 'user' | 'guest';

// service.ts
import type { User, Role } from './types';

function greetUser(user: User, role: Role): string {
  return `Hello ${user.name}, you are a ${role}`;
}

const user: User = { id: 1, name: 'Alice', email: 'alice@example.com' };
console.log(greetUser(user, 'admin'));

The 'import type' syntax ensures these imports are erased at compile time. They produce no runtime code, which helps avoid circular dependencies and reduces bundle size.

Barrel Files and Re-exports

// models/user.ts
export interface User { id: number; name: string; }

// models/product.ts
export interface Product { id: number; title: string; price: number; }

// models/index.ts (barrel file)
export { User } from './user';
export { Product } from './product';
export type { User as UserType } from './user';

// app.ts - single clean import
// import { User, Product } from './models';

const user: User = { id: 1, name: 'Bob' };
const product: Product = { id: 101, title: 'Keyboard', price: 79.99 };
console.log(user.name);
console.log(product.title);

The barrel file (index.ts) re-exports from individual modules, letting consumers use a single import path. You can also rename exports during re-export.

Key points

Concepts covered

import, export, re-exports, type-only imports, default exports, named exports, barrel files