Learn TypeScript With Me in 14 Days Day 8

Posted on 8 October 2024 Reading time: 8 min read
Learn TypeScript With Me in 14 Days Day 8

Day 8: Modules in TypeScript

Organising Your Code with Modules in TypeScript

We have just gone past half-way through learning TypeScript in 14 days. If you have been following along, well done! In this part of the series, we will focus on Modules in TypeScript. As projects grow, organising code into manageable and reusable pieces becomes crucial. Modules help you break down code into logical units that can be reused across files. By the end of this session, you’ll understand how to create, import, and export modules and refactor our Task Manager project using modules.

Why Use Modules in TypeScript?

Modules allow you to encapsulate functionality and make it reusable across your project. Instead of having all your code in a single file, you can split your code into smaller, more manageable pieces. These pieces can then be imported and exported between files, improving code organisation, maintainability, and scalability.

Creating and Exporting Modules

In TypeScript, a module is simply a file. Anything defined in a file is scoped to that file by default. You can make it available to other files by using export statements.

Example of Exporting a Module

Let’s assume you have a task.ts file that defines a Task class. You can export it as a module like this:

// task.ts
export class Task {
  id: number;
  title: string;
  completed: boolean;

  constructor(id: number, title: string, completed: boolean = false) {
    this.id = id;
    this.title = title;
    this.completed = completed;
  }

  toggleCompletion(): void {
    this.completed = !this.completed;
  }
}

Explanation:

  • export keyword: The export keyword makes the Task class available for import in other files.

Importing Modules

Now that we’ve exported the Task class from task.ts, let’s import it in another file. For example, in index.ts, we can import the Task class and use it.

Example of Importing a Module

// index.ts
import { Task } from './task';

const task1 = new Task(1, 'Learn TypeScript');
const task2 = new Task(2, 'Build a Task Manager');

console.log(task1, task2);

Explanation:

  • import { Task }: This imports the Task class from the task.ts file.
  • Relative Path: Notice the ./task path. This is a relative import pointing to the file where the Task class is defined.

Named Exports vs Default Exports

TypeScript supports two types of exports: named exports and default exports.

Named Exports:

Named exports allow you to export multiple things from a module, and when you import them, you must use the same names.

// utils.ts (Named Exports)
export function logMessage(message: string): void {
  console.log(message);
}

export function logError(error: string): void {
  console.error(error);
}

// index.ts
import { logMessage, logError } from './utils';

logMessage('Everything is working!');
logError('Something went wrong!');

Default Exports:

Default exports allow you to export a single thing from a module. When you import it, you can give it any name.

// settings.ts (Default Export)
export default {
  theme: 'dark',
  language: 'en'
};

// index.ts
import settings from './settings';

console.log(settings.theme); // Output: dark

When to Use Named vs Default Exports:

  • Named Exports: Use when you want to export multiple things from a file, or when you want to import a specific part of a module.
  • Default Exports: Use when your file only exports a single value, class, or function.

Organising the Task Manager Project with Modules

Now that we understand how modules work, let’s refactor the Task Manager project to use multiple modules, separating different parts of the code into logical units.

1. Create task.ts for Task Class

We’ll move the Task class into its own module (task.ts) and export it.

// task.ts
export class Task {
  id: number;
  title: string;
  completed: boolean;

  constructor(id: number, title: string, completed: boolean = false) {
    this.id = id;
    this.title = title;
    this.completed = completed;
  }

  toggleCompletion(): void {
    this.completed = !this.completed;
  }

  getDetails(): string {
    return `${this.title} | ${this.completed ? 'Completed' : 'Incomplete'}`;
  }
}

2. Create taskManager.ts to Manage Task Operations

Next, we’ll create a taskManager.ts file to manage adding, removing, and toggling tasks.

// taskManager.ts
import { Task } from './task';

export class TaskManager {
  private tasks: Task[] = [];

  addTask(title: string): Task {
    const newTask = new Task(this.tasks.length + 1, title);
    this.tasks.push(newTask);
    return newTask;
  }

  removeTask(id: number): void {
    this.tasks = this.tasks.filter(task => task.id !== id);
  }

  toggleTaskCompletion(id: number): void {
    const task = this.tasks.find(task => task.id === id);
    if (task) {
      task.toggleCompletion();
    }
  }

  getAllTasks(): Task[] {
    return this.tasks;
  }
}

3. Import and Use Modules in index.ts

Finally, in index.ts, we’ll import the TaskManager and use it to manage tasks.

// index.ts
import { TaskManager } from './taskManager';

const taskManager = new TaskManager();

taskManager.addTask('Learn TypeScript');
taskManager.addTask('Build Task Manager');
taskManager.toggleTaskCompletion(1);

console.log(taskManager.getAllTasks().map(task => task.getDetails()));

Explanation:

  • We separated task-related logic into modules (task.ts and taskManager.ts).
  • We used named exports to export the Task and TaskManager classes.
  • This modular structure improves code organisation and makes it easier to maintain and scale the project.

Step 5: Re-Exporting Modules

In larger projects, you may have multiple modules and want to re-export them from a single file for convenience. Let’s say you have task.ts and taskManager.ts, you can re-export them from an index.ts file.

Example of Re-Exporting Modules

// index.ts (Re-exporting)
export { Task } from './task';
export { TaskManager } from './taskManager';

Now, instead of importing from individual files, you can import everything from index.ts.

// app.ts
import { Task, TaskManager } from './index';

const taskManager = new TaskManager();
taskManager.addTask('Learn TypeScript');
console.log(taskManager.getAllTasks());

Step 6: Avoiding Circular Dependencies

One thing to be cautious about when working with modules is circular dependencies. A circular dependency occurs when two or more modules depend on each other, creating a cycle. This can lead to issues where modules fail to load correctly or cause runtime errors.

Example of a Circular Dependency:

// A.ts
import { B } from './B';
export class A {}

// B.ts
import { A } from './A';
export class B {}

Solution:

To avoid circular dependencies:

  • Break dependencies by creating a new module to handle shared logic.
  • Use interfaces or dependency injection to decouple the modules.

How Interfaces Help Avoid Circular Dependencies

One way to avoid circular dependencies is by using interfaces to define the types and behaviours needed by modules, instead of having modules directly depend on each other. This allows one module to depend on the structure (interface) rather than the implementation of another module.

Example Scenario: Circular Dependency

Let’s imagine a scenario where we have two classes: TaskManager and TaskLogger. The TaskManager is responsible for managing tasks, and the TaskLogger is responsible for logging when a task is added or removed.

// taskManager.ts
import { TaskLogger } from './taskLogger'; // Circular dependency
export class TaskManager {
  constructor(private logger: TaskLogger) {}

  addTask(task: string): void {
    this.logger.log(`Added task: ${task}`);
  }
}

// taskLogger.ts
import { TaskManager } from './taskManager'; // Circular dependency
export class TaskLogger {
  constructor(private manager: TaskManager) {}

  log(message: string): void {
    console.log(message);
  }
}

Here:

  • TaskManager imports TaskLogger.
  • TaskLogger imports TaskManager. This creates a circular dependency, which can cause issues at runtime.

Use an Interface to Decouple the Dependencies

To avoid circular dependencies, we can decouple these classes by using an interface. Instead of TaskLogger directly depending on TaskManager, it will depend on an interface that defines the behaviour TaskManager needs, but without requiring the full class definition.

1. Define an Interface for Task Logging

// loggerInterface.ts
export interface Logger {
  log(message: string): void;
}

This interface defines the behaviour needed for logging tasks (log), but it doesn’t import or depend on TaskManager.

2. Implement the Interface in TaskLogger

Now, we can implement the Logger interface in the TaskLogger class.

// taskLogger.ts
import { Logger } from './loggerInterface';

export class TaskLogger implements Logger {
  log(message: string): void {
    console.log(`[TaskLogger] ${message}`);
  }
}

Here, TaskLogger implements the Logger interface but doesn’t depend on TaskManager directly, breaking the cycle.

3. Refactor TaskManager to Use the Logger Interface

Now, TaskManager only depends on the Logger interface, rather than the full TaskLogger class. This breaks the circular dependency.

// taskManager.ts
import { Logger } from './loggerInterface';

export class TaskManager {
  constructor(private logger: Logger) {}

  addTask(task: string): void {
    this.logger.log(`Added task: ${task}`);
  }
}

4. Wiring It All Together

Finally, in index.ts (or whichever file initialises the application), we wire everything together. This is where we inject the actual TaskLogger implementation into TaskManager.

// index.ts
import { TaskManager } from './taskManager';
import { TaskLogger } from './taskLogger';

const logger = new TaskLogger();
const taskManager = new TaskManager(logger);

taskManager.addTask('Learn TypeScript');

Explanation:

  • TaskManager now depends on the Logger interface, not the TaskLogger class, breaking the circular dependency.
  • We inject TaskLogger when we initialise TaskManager in the index.ts file. This way, the TaskManager and TaskLogger classes are decoupled.

What’s Next?

Today, we learnt how to use Modules in TypeScript to organise our code into reusable and maintainable pieces. We applied modules to the Task Manager project, improving its structure. Tomorrow, we’ll dive into TypeScript’s Advanced Types, such as mapped types and conditional types to enhance our TypeScript skills further. If you have any questions or comments, feel free contact me at steve [at] stevepopoola.uk.