- read

Mastering Modular TypeScript

Tomerzaidler 34

Mastering Modular TypeScript

Empowering Your Apps with Dependency Injection

Tomerzaidler
JavaScript in Plain English
8 min readOct 4

--

In this article, I will cover the concept of writing modular TypeScript code, leveraging the powerful technique of Dependency Injection. Rather than a tedious manual, think of this as a practical guide to enhance the organization and maintainability of your codebase. We will embark on a journey to understand how to build well-structured, adaptable software components using TypeScript.

Understanding Modularity

Modularity is the cornerstone of software design. It’s the art of breaking down a complex system into smaller, self-contained pieces. Just like in our skyscraper analogy, each module represents a distinct unit, making it easier to understand, maintain, and enhance.

Why is modularity so crucial? Here are some compelling reasons:

Easier Maintenance: Picture this — when you need to fix a leaky pipe on the 20th floor, you don’t have to worry about the wiring on the 15th floor. Each module is self-contained and easier to maintain.

Improved Collaboration: Multiple developers can work on different modules simultaneously without stepping on each other’s toes. Think of it as a harmonious construction crew, each working on their designated floor.

Enhanced Testing: Testing becomes more efficient. You can isolate and test individual modules without affecting the entire system, ensuring that changes in one area don’t break something elsewhere.

Scalability: As your skyscraper (or codebase) grows, you can easily add or replace modules without tearing down the entire structure. This flexibility allows your software to adapt to changing requirements.

Code Reusability: Modular components are like Lego bricks; you can use them in different projects or even within the same project, saving time and effort.

So, as we embark on this journey, remember that mastering modularity is about making your code robust, maintainable, and scalable. Ready to dive deeper into the world of Dependency Injection to supercharge your modularity efforts?

Dependency Injection

Now that I have established the importance of modularity, I will introduce you to one of the most powerful tools in our arsenal: Dependency Injection (DI). Think of DI as the magical wand that brings flexibility, reusability, and maintainability to your modular TypeScript codebase.

At its core, Dependency Injection is a design pattern that promotes loose coupling between components in your software. In simpler terms, it’s about decoupling dependencies from the components that use them. Here’s a breakdown of the essence of Dependency Injection:

  • Dependencies are like Lego Bricks: In software development, we often use various components or services to accomplish specific tasks. These components are like Lego bricks, each with a unique function.
TypeScript class representing a service
  • Avoiding Tight Coupling: In a non-DI scenario, your code components might create their dependencies, similar to each Lego brick manufacturing its custom connectors. If you ever want to replace a brick or upgrade it, you’d need to modify every connector — a complex and error-prone process.
  • Using Dependency Injection: With Dependency Injection, instead of creating their dependencies, components receive them from an external source. Think of it as a central Lego supply store that provides connectors. If you want to change a brick or upgrade it, you simply get the new connectors from the store, and your code components remain unchanged.

Dependency Injection is a real game-changer. It not only simplifies your code but also makes it more maintainable and testable. In the upcoming sections, we’ll delve into the practical implementation of Dependency Injection in TypeScript, demonstrating how it empowers you to create well-structured, flexible, and easily maintainable code modules.

Dependency Injection with an IoC Container

In this section, we’ll explore an advanced approach to Dependency Injection (DI) using an Inversion of Control (IoC) container. An IoC container is a tool that manages the creation and resolution of dependencies in your application, making DI even more efficient and organized.

Understanding IoC Containers:

IoC containers, like InversifyJS, provide a centralized place for registering and resolving dependencies. They help maintain a clear separation between your components and their dependencies, making your codebase more modular and testable.

Let’s build small API using InversifyJS:

First, you’ll need to install InversifyJS and reflect-metadata:

npm install inversify reflect-metadata

Step 1: Import Dependencies

Import inversify.js dependencies

Step 2: Define our interfaces

Step 3: Create our service “InMemoryTaskRepository”

Step 4: Create a Controller

Step 5: Define an TaskServer class to encapsulate Express app, IOC container and routes

TaskServer Implementation

This TaskServer class is designed to encapsulate an Express app, an Inversion of Control (IOC) container, and routes for handling tasks. Let’s break down each method:

loadDependencies:
Parameters: Accepts an array of Dependency objects as dependencies.
Functionality:
Iterates through the provided dependencies and binds them to the IOC container using their constructor names as symbols.
This allows the IOC container to manage the lifecycle and resolution of these dependencies.

configureRoutes:
Functionality:
Defines various HTTP routes for handling tasks using different HTTP methods (GET, POST, PUT, DELETE).
Uses the TaskController from the IOC container to handle the requests for each route.
For example, a GET request to ‘/tasks/:id’ invokes the getTaskById method of the TaskController.

listen:
Functionality:
Listens for incoming requests on a specified port (in this case, port 3000).
Outputs a message to the console when the server is successfully running.

Public Interface:
The class exposes the listen method, which is intended to be called externally to start the server.
Overall, this class sets up a server for handling tasks using Express, employs an IOC container for dependency management, and organizes routes for different task-related operations. It follows the principles of modularization and dependency injection to maintain a clean and scalable code structure.

Final step: Instantiate our API server with the dependencies

Full Code Example:


interface Task {
id: number;
title: string;
description: string;
completed: boolean;
}

interface TaskRepository {
getTaskById(id: number): Task | undefined;
createTask(task: Task): Task;
updateTask(id: number, updatedTask: Task): Task | undefined;
deleteTask(id: number): void;
}

type DependencyArgs = Array<any>;
type Dependency<T = any> = new (...args: DependencyArgs) => T;

@injectable()
class InMemoryTaskRepository implements TaskRepository {
private tasks: Task[] = [];
private currentId = 1;

getTaskById(id: number): Task | undefined {
return this.tasks.find((task) => task.id === id);
}

createTask(task: Task): Task {
task.id = this.currentId++;
this.tasks.push(task);
return task;
}

updateTask(id: number, updatedTask: Task): Task | undefined {
const index = this.tasks.findIndex((task) => task.id === id);
if (index === -1) return undefined;
this.tasks[index] = { ...updatedTask, id };
return this.tasks[index];
}

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

// Define our API controller
@injectable()
class TaskController {
constructor(private taskRepository: TaskRepository) {}

getTaskById(req: express.Request, res: express.Response) {
const task = this.taskRepository.getTaskById(req.param?.id);
res.json(task);
}

createTask(req: express.Request, res: express.Response) {
const task = this.taskRepository.createTask(req.body?.id);
res.json(task);
}

updateTask(req: express.Request, res: express.Response) {
const task = this.taskRepository.updateTask(req.param?.id, req.body?.updatedTask);
res.json(task);
}

deleteTask(req: express.Request, res: express.Response) {
const task = this.taskRepository.deleteTask(req.param?.id);
res.json(task);
}
}

class TaskServer {
private app: express.Application;
private container: Container;

constructor(dependencies: Array<Dependency>) {
this.app = express();
this.container = new Container();
this.loadDependencies(dependencies);
this.configureRoutes();
}

private loadDependencies(dependencies: Array<Dependency>) {
for (const dependency of dependencies) {
this.container.bind<Dependency>(Symbol(dependency.constructor.name)).to(dependency);
}
}

private configureRoutes() {
this.app.get('/tasks/:id', (req: Request, res: Response) => this.container.get(TaskController).getTaskById(req, res));
this.app.post('/tasks', (req: Request, res: Response) => this.container.get(TaskController).createTask(req, res));
this.app.put('/tasks/:id', (req: Request, res: Response) => this.container.get(TaskController).updateTask(req, res));
this.app.delete('/tasks/:id', (req: Request, res: Response) => this.container.get(TaskController).deleteTask(req, res));
}

public listen() {
const port = 3000;
this.app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
}
}

const taskServer = new TaskServer([TaskController, InMemoryTaskRepository]);
taskServer.listen();

Testing Your Modular Code

In a modular codebase with Dependency Injection, writing unit tests becomes more manageable due to the decoupling of components. Here’s how to write unit tests for components with injected dependencies using Jest, a popular testing framework for TypeScript:

Unit test with dependency injection

Testing plays a crucial role in a modular codebase, and it offers several benefits:

Verification of Isolated Components: In a modular codebase, each component has a well-defined responsibility. Unit tests verify that individual components perform their specific tasks correctly, ensuring that each piece of the puzzle works as expected.

Detecting Issues Early: Unit tests catch issues early in the development process, reducing the chances of bugs propagating to other parts of the codebase. This early detection saves time and resources in the long run.

Simplified Debugging: When a test fails, it pinpoints the exact location of the problem. This makes debugging and troubleshooting much more straightforward than dealing with issues in a monolithic codebase.

Encouraging Code Quality: Writing tests encourages developers to write clean, maintainable, and well-documented code. Modular components with clear interfaces are easier to test and maintain.

Ease of Refactoring: In a modular codebase, you can refactor or make changes to a component with confidence, knowing that your unit tests will catch regressions. This promotes code evolution and adaptation to changing requirements.

Conclusion

Dependency Injection is a powerful technique that can greatly enhance the modularity, testability, and maintainability of your TypeScript applications. By decoupling components and managing dependencies externally, you can build more flexible and scalable applications. Whether you choose to use decorators or a dependency injection container, incorporating DI into your development workflow can lead to cleaner and more maintainable code.

In this article, we explored the concept of Dependency Injection, its benefits, and practical applications in TypeScript. We discussed how to implement DI using decorators and demonstrated the usage of a popular DI container, InversifyJS.

By mastering Dependency Injection in TypeScript, you can take your application development to the next level, empowering your apps with modularity, testability, and maintainability. So go ahead, embrace DI, and unlock the full potential of your TypeScript projects.

Additional Resources

To further enhance your understanding of modular TypeScript code and Dependency Injection, explore these additional resources:

1. TypeScript Official Documentation: TypeScript Handbook

2. Dependency Injection Frameworks:

  • InversifyJS: A powerful Inversion of Control (IoC) container for TypeScript and JavaScript.
  • NestJS: NestJS, a powerful TypeScript framework, harnesses the capabilities of Dependency Injection (DI) to facilitate clean, modular, and testable code architectures in server-side applications.
  • Angular Dependency Injection: Learn how Angular leverages Dependency Injection for building scalable web applications.

3. Books:

4. Articles and Tutorials:

In Plain English

Thank you for being a part of our community! Before you go: