Dependency Injection is a technique where an object receives its dependencies from an external source rather than creating them itself. In TypeScript, a dependency can be another class, a service, or a module. For example, consider a UserService
class that depends on a UserRepository
class to fetch user data. Instead of creating an instance of UserRepository
inside the UserService
, we can pass it as a parameter.
// UserRepository class
class UserRepository {
getUser() {
return { name: 'John Doe' };
}
}
// UserService class
class UserService {
constructor(private userRepository: UserRepository) {}
getUserName() {
const user = this.userRepository.getUser();
return user.name;
}
}
// Usage
const userRepository = new UserRepository();
const userService = new UserService(userRepository);
console.log(userService.getUserName());
Constructor injection is the most common method of DI in TypeScript. It involves passing dependencies as parameters to the constructor of a class.
class Logger {
log(message: string) {
console.log(message);
}
}
class Calculator {
constructor(private logger: Logger) {}
add(a: number, b: number) {
const result = a + b;
this.logger.log(`The result of ${a} + ${b} is ${result}`);
return result;
}
}
const logger = new Logger();
const calculator = new Calculator(logger);
calculator.add(2, 3);
Property injection involves setting the dependencies as properties of a class after the class has been instantiated.
class EmailService {
sendEmail(to: string, message: string) {
console.log(`Sending email to ${to}: ${message}`);
}
}
class NotificationService {
emailService: EmailService;
sendNotification(to: string, message: string) {
if (this.emailService) {
this.emailService.sendEmail(to, message);
}
}
}
const emailService = new EmailService();
const notificationService = new NotificationService();
notificationService.emailService = emailService;
notificationService.sendNotification('[email protected]', 'Hello!');
Method injection involves passing dependencies as parameters to a method of a class.
class DataFetcher {
fetchData() {
return { data: 'Sample data' };
}
}
class ReportGenerator {
generateReport(dataFetcher: DataFetcher) {
const data = dataFetcher.fetchData();
console.log(`Report generated with data: ${data.data}`);
}
}
const dataFetcher = new DataFetcher();
const reportGenerator = new ReportGenerator();
reportGenerator.generateReport(dataFetcher);
A DI container is a tool that manages the creation and injection of dependencies. In TypeScript, libraries like InversifyJS
can be used as a DI container.
import 'reflect-metadata';
import { Container, injectable, inject } from 'inversify';
// Define an interface
interface IUserRepository {
getUser(): { name: string };
}
// Implement the interface
@injectable()
class UserRepository implements IUserRepository {
getUser() {
return { name: 'Jane Smith' };
}
}
// Define a service
@injectable()
class UserService {
constructor(@inject('IUserRepository') private userRepository: IUserRepository) {}
getUserName() {
const user = this.userRepository.getUser();
return user.name;
}
}
// Create a container
const container = new Container();
container.bind<IUserRepository>('IUserRepository').to(UserRepository);
container.bind<UserService>(UserService).toSelf();
// Resolve the service
const userService = container.get<UserService>(UserService);
console.log(userService.getUserName());
Dependencies can have different lifetimes, such as singleton, transient, or scoped.
In InversifyJS
, we can manage lifetimes as follows:
// Singleton
container.bind<IUserRepository>('IUserRepository').to(UserRepository).inSingletonScope();
// Transient
container.bind<IUserRepository>('IUserRepository').to(UserRepository).inTransientScope();
Make sure that all dependencies of a class are clearly defined and passed as parameters or set as properties. Avoid implicit dependencies that are created inside the class.
Using interfaces for dependencies makes the code more flexible. It allows us to easily swap out implementations of dependencies as long as they adhere to the same interface.
interface ILogger {
log(message: string): void;
}
@injectable()
class ConsoleLogger implements ILogger {
log(message: string) {
console.log(message);
}
}
@injectable()
class FileLogger implements ILogger {
log(message: string) {
// Code to write to a file
}
}
@injectable()
class MyService {
constructor(@inject('ILogger') private logger: ILogger) {}
doSomething() {
this.logger.log('Doing something...');
}
}
Only inject the dependencies that a class actually needs. Avoid injecting unnecessary dependencies, as it can make the class more complex and harder to test.
Dependency Injection is a powerful design pattern in TypeScript that can significantly improve the modularity, testability, and maintainability of your code. By understanding the fundamental concepts, usage methods, common practices, and best practices, you can effectively use DI in your TypeScript projects. Whether you are building a small application or a large - scale enterprise system, DI will help you write better code.