Dependency Injection in TypeScript
DI inverts control of creating dependencies, making code more testable and modular.
Manual DI (Composition Root)
// Define interfaces
interface UserRepository {
findById(id: string): Promise<User | null>;
save(user: User): Promise<User>;
}
interface EmailService {
sendWelcome(email: string): Promise<void>;
}
// Implementations
class PostgresUserRepository implements UserRepository { /* ... */ }
class SendgridEmailService implements EmailService { /* ... */ }
class MockEmailService implements EmailService {
sentEmails: string[] = [];
async sendWelcome(email: string) { this.sentEmails.push(email); }
}
// Service with injected dependencies
class UserService {
constructor(
private readonly userRepo: UserRepository,
private readonly emailService: EmailService
) {}
async register(email: string, password: string): Promise<User> {
const user = await this.userRepo.save(User.create(email, password));
await this.emailService.sendWelcome(email);
return user;
}
}
// Composition root (app startup)
const userRepo = new PostgresUserRepository(db);
const emailService = new SendgridEmailService(apiKey);
const userService = new UserService(userRepo, emailService);
// In tests
const mockEmail = new MockEmailService();
const testUserService = new UserService(new InMemoryUserRepository(), mockEmail);
InversifyJS Container
import { Container, injectable, inject } from 'inversify';
const TYPES = {
UserRepository: Symbol('UserRepository'),
EmailService: Symbol('EmailService'),
UserService: Symbol('UserService'),
};
@injectable()
class PostgresUserRepository implements UserRepository { /* ... */ }
@injectable()
class SendgridEmailService implements EmailService { /* ... */ }
@injectable()
class UserService {
constructor(
@inject(TYPES.UserRepository) private userRepo: UserRepository,
@inject(TYPES.EmailService) private emailService: EmailService
) {}
}
// Configure container
const container = new Container();
container.bind<UserRepository>(TYPES.UserRepository).to(PostgresUserRepository);
container.bind<EmailService>(TYPES.EmailService).to(SendgridEmailService);
container.bind<UserService>(TYPES.UserService).to(UserService);
// Resolve
const userService = container.get<UserService>(TYPES.UserService);
NestJS DI
// service.ts
@Injectable()
export class UserService {
constructor(
@InjectRepository(User) private userRepo: Repository<User>,
private emailService: EmailService,
private configService: ConfigService
) {}
async create(dto: CreateUserDto): Promise<User> {
const user = this.userRepo.create(dto);
const saved = await this.userRepo.save(user);
await this.emailService.sendWelcome(saved.email);
return saved;
}
}
// module.ts
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UserService, EmailService],
exports: [UserService],
})
export class UserModule {}
// Testing with NestJS
describe('UserService', () => {
let service: UserService;
let mockEmailService: jest.Mocked<EmailService>;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
UserService,
{
provide: EmailService,
useValue: { sendWelcome: jest.fn() },
},
{
provide: getRepositoryToken(User),
useValue: { create: jest.fn(), save: jest.fn() },
},
],
}).compile();
service = module.get<UserService>(UserService);
mockEmailService = module.get(EmailService);
});
});
Scopes
@Injectable({ scope: Scope.DEFAULT }) // Singleton (default)
class DatabaseConnection {}
@Injectable({ scope: Scope.REQUEST }) // New instance per request
class RequestContextService {}
@Injectable({ scope: Scope.TRANSIENT }) // New instance per injection
class TaskRunner {}
DI containers shine in large applications with complex dependency graphs.