Skip to main content

TypeScript Modules

Introduction

As your TypeScript applications grow in size and complexity, organizing your code becomes essential for maintainability. TypeScript modules provide a powerful way to structure your code, encapsulate functionality, and control what parts of your code are visible to other parts of your application.

In this tutorial, we'll explore TypeScript modules - how they work, how to import and export code between files, and best practices for using modules effectively in your TypeScript projects.

What Are TypeScript Modules?

A module in TypeScript is a file that contains code - variables, functions, classes, interfaces, or types - that can be exported to be used in other files. Any file containing a top-level import or export is considered a module.

Modules help you:

  • Organize code: Break large applications into smaller, manageable pieces
  • Encapsulate code: Hide implementation details and expose only what's necessary
  • Reuse code: Share functionality across different parts of your application
  • Control dependencies: Explicitly declare what your code needs from other modules

Module Formats in TypeScript

TypeScript supports several module formats:

  1. ES Modules (ESM) - Modern JavaScript standard, using import and export statements
  2. CommonJS - The Node.js module system, using require() and module.exports
  3. AMD - Used mainly in browser applications with RequireJS
  4. UMD - Universal Module Definition that works across multiple environments
  5. System - SystemJS dynamic module loader

For most modern applications, ES Modules are recommended and will be our focus in this tutorial.

Exporting from a Module

Let's look at the different ways to export code from a TypeScript module.

Named Exports

You can export specific variables, functions, classes, interfaces, or types using the export keyword:

typescript
// math.ts
export const PI = 3.14159;

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

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

export interface Shape {
area(): number;
}

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

You can also declare items first and export them later:

typescript
// math.ts
const PI = 3.14159;
function add(a: number, b: number): number {
return a + b;
}
function subtract(a: number, b: number): number {
return a - b;
}

// Export multiple items at once
export { PI, add, subtract };

Default Exports

Each module can have one default export, which makes it easier to import:

typescript
// calculator.ts
class Calculator {
add(a: number, b: number): number {
return a + b;
}

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

export default Calculator;

Mixing Default and Named Exports

You can combine default and named exports in the same file:

typescript
// math.ts
export const PI = 3.14159;

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

export default class MathUtils {
static square(x: number): number {
return x * x;
}

static cube(x: number): number {
return x * x * x;
}
}

Importing from a Module

Now let's see how to import the exported items from our modules.

Importing Named Exports

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

console.log(PI); // 3.14159
console.log(add(5, 3)); // 8
console.log(subtract(10, 4)); // 6

You can also rename imports to avoid naming conflicts:

typescript
// app.ts
import { PI as MathPI, add as mathAdd } from './math';

console.log(MathPI); // 3.14159
console.log(mathAdd(5, 3)); // 8

Importing Default Exports

typescript
// app.ts
import Calculator from './calculator';

const calc = new Calculator();
console.log(calc.add(5, 3)); // 8

Importing Both Default and Named Exports

typescript
// app.ts
import MathUtils, { PI, add } from './math';

console.log(PI); // 3.14159
console.log(add(5, 3)); // 8
console.log(MathUtils.square(4)); // 16

Importing All Exports

You can import all exports from a module into a single namespace:

typescript
// app.ts
import * as MathModule from './math';

console.log(MathModule.PI); // 3.14159
console.log(MathModule.add(5, 3)); // 8

// If there's a default export, it will be available as default
const utils = new MathModule.default();

Module Resolution Strategies

TypeScript supports different module resolution strategies that determine how the compiler locates modules:

  1. Classic: A simplified resolution strategy used mainly for backward compatibility
  2. Node: Mirrors how Node.js resolves modules (recommended for Node.js applications)
  3. Node16 or NodeNext: Modern resolution strategies for newer Node.js versions with ESM support

You can specify the resolution strategy in your tsconfig.json:

json
{
"compilerOptions": {
"moduleResolution": "node",
// other options...
}
}

Path Mapping

TypeScript allows you to define path aliases in your tsconfig.json to make imports cleaner and more maintainable:

json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@utils/*": ["src/utils/*"],
"@models/*": ["src/models/*"]
}
}
}

This allows you to import like this:

typescript
// Instead of relative paths like this:
import { formatDate } from '../../utils/date';

// You can use:
import { formatDate } from '@utils/date';

Real-World Example: Building a Todo Application

Let's see how modules can be used in a real-world scenario by building a simple todo application with modular architecture:

Model Module

typescript
// src/models/todo.ts
export interface Todo {
id: number;
title: string;
completed: boolean;
createdAt: Date;
}

export type TodoStatus = 'all' | 'active' | 'completed';

Service Module

typescript
// src/services/todo-service.ts
import { Todo } from '../models/todo';

export default class TodoService {
private todos: Todo[] = [];
private nextId = 1;

addTodo(title: string): Todo {
const todo: Todo = {
id: this.nextId++,
title,
completed: false,
createdAt: new Date()
};

this.todos.push(todo);
return todo;
}

getTodos(): Todo[] {
return [...this.todos]; // Return a copy
}

toggleTodo(id: number): Todo | undefined {
const todo = this.todos.find(todo => todo.id === id);
if (todo) {
todo.completed = !todo.completed;
}
return todo;
}

deleteTodo(id: number): boolean {
const initialLength = this.todos.length;
this.todos = this.todos.filter(todo => todo.id !== id);
return initialLength > this.todos.length;
}
}

Utilities Module

typescript
// src/utils/format-utils.ts
export function formatDate(date: Date): string {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}

export function truncateText(text: string, maxLength: number = 50): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + '...';
}

UI Module

typescript
// src/ui/todo-list.ts
import { Todo, TodoStatus } from '../models/todo';
import TodoService from '../services/todo-service';
import { formatDate, truncateText } from '../utils/format-utils';

export class TodoList {
private todoService: TodoService;
private currentFilter: TodoStatus = 'all';

constructor(todoService: TodoService) {
this.todoService = todoService;
}

addTodo(title: string): void {
if (!title.trim()) return;

const todo = this.todoService.addTodo(title);
console.log(`Added new todo: "${truncateText(todo.title, 20)}" on ${formatDate(todo.createdAt)}`);
this.renderTodos();
}

toggleTodo(id: number): void {
const todo = this.todoService.toggleTodo(id);
if (todo) {
console.log(`Todo "${truncateText(todo.title, 20)}" marked as ${todo.completed ? 'completed' : 'active'}`);
this.renderTodos();
}
}

deleteTodo(id: number): void {
if (this.todoService.deleteTodo(id)) {
console.log(`Todo deleted successfully`);
this.renderTodos();
}
}

setFilter(filter: TodoStatus): void {
this.currentFilter = filter;
this.renderTodos();
}

private renderTodos(): void {
const allTodos = this.todoService.getTodos();

// Filter todos based on current filter
let filteredTodos: Todo[];
if (this.currentFilter === 'active') {
filteredTodos = allTodos.filter(todo => !todo.completed);
} else if (this.currentFilter === 'completed') {
filteredTodos = allTodos.filter(todo => todo.completed);
} else {
filteredTodos = allTodos;
}

console.log(`------ ${this.currentFilter.toUpperCase()} TODOS ------`);
filteredTodos.forEach(todo => {
const status = todo.completed ? '✓' : '○';
console.log(`${status} [${todo.id}] ${todo.title} (${formatDate(todo.createdAt)})`);
});
console.log(`----------------------------`);
}
}

Main Application

typescript
// src/app.ts
import TodoService from './services/todo-service';
import { TodoList } from './ui/todo-list';

// Create instances
const todoService = new TodoService();
const todoList = new TodoList(todoService);

// Add some todos
todoList.addTodo('Learn TypeScript Modules');
todoList.addTodo('Build a modular application');
todoList.addTodo('Write documentation');

// Toggle the first todo as completed
todoList.toggleTodo(1);

// Show active todos only
todoList.setFilter('active');

// Delete a todo
todoList.deleteTodo(3);

// Show all todos
todoList.setFilter('all');

In this example, we've separated our application into logical modules:

  • Models contain data structures
  • Services handle business logic
  • Utils provide helper functions
  • UI components handle rendering and user interaction

The main app file simply imports and uses these modules without needing to know their implementation details.

Module Bundlers

When building web applications with TypeScript and modules, you'll typically use a module bundler like:

  1. Webpack - A powerful and flexible bundler
  2. Rollup - Great for libraries and smaller applications
  3. Parcel - Zero configuration bundler
  4. esbuild - Extremely fast bundler

These bundlers allow you to:

  • Bundle multiple modules into a single file
  • Tree-shake to remove unused code
  • Split code into chunks for lazy loading
  • Process and transform assets

Dynamic Imports

TypeScript supports dynamic imports for loading modules on demand:

typescript
// Regular static import
import { someFunction } from './some-module';

// Dynamic import (returns a Promise)
async function loadModule() {
const module = await import('./some-module');
module.someFunction();
}

This is useful for:

  • Lazy loading modules when needed
  • Reducing initial bundle size
  • Loading modules conditionally

Best Practices for TypeScript Modules

  1. Prefer named exports for most cases as they provide better clarity and are easier to refactor.
  2. Use default exports sparingly and mainly for modules that export a single primary element.
  3. Export interfaces and types to share type definitions across your application.
  4. Keep modules focused on a single responsibility.
  5. Avoid circular dependencies between modules.
  6. Use barrel files (index.ts) to simplify imports from complex module structures:
typescript
// src/utils/index.ts
export * from './date-utils';
export * from './string-utils';
export * from './number-utils';

// Now you can import everything with:
// import { formatDate, truncateText, formatNumber } from './utils';
  1. Organize modules by feature rather than by type (models, services, components) for large applications.

Summary

TypeScript modules provide a powerful way to organize your code into reusable, maintainable units. In this tutorial, we've covered:

  • Exporting and importing code in TypeScript modules
  • Different types of exports (named, default)
  • Module resolution strategies
  • Path mapping for cleaner imports
  • A real-world example demonstrating modular architecture
  • Dynamic imports for lazy loading
  • Best practices for using modules effectively

By mastering TypeScript modules, you'll be able to create well-structured applications that are easier to maintain, test, and scale.

Additional Resources

Exercises

  1. Create a simple calculator module with functions for add, subtract, multiply, and divide. Then import and use these functions in another file.

  2. Convert an existing non-modular TypeScript application into one that uses modules.

  3. Create a barrel file (index.ts) that re-exports multiple related modules to simplify imports.

  4. Implement a feature that uses dynamic imports to lazy load a module only when it's needed.

  5. Set up path aliases in a tsconfig.json file to use absolute imports instead of relative ones.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)