Пошук уроків, статей та іншого контенту
Побудуйте dependency injection для класів і типізуйте залежності, конструктори та взаємодію між компонентами.
Dependency injection (DI) — це передавання об’єкту його залежностей ззовні замість створення цих залежностей усередині самого об’єкта.
Без DI клас самостійно створює залежності:
class UserService {
private readonly repository = new InMemoryUserRepository();
private readonly logger = new ConsoleLogger();
createUser(name: string) {
this.logger.info("Створення користувача");
return this.repository.save({ name });
}
}Такий код має кілька проблем:
UserService жорстко пов’язаний із конкретними реалізаціями;
складно замінити базу даних на тестову;
складно протестувати логіку без виведення повідомлень у консоль;
зміна способу створення залежностей змушує змінювати сам сервіс.
З DI UserService не створює залежності, а отримує їх через конструктор:
class UserService {
constructor(
private readonly repository: UserRepository,
private readonly logger: Logger,
) {}
}Тепер зовнішній код відповідає за створення об’єктів і передавання їх сервісу.
Залежність варто описувати не конкретним класом, а контрактом — інтерфейсом або типом.
interface Logger {
info(message: string, context?: Record<string, unknown>): void;
error(message: string, context?: Record<string, unknown>): void;
}
interface UserRepository {
save(user: NewUser): User;
findById(id: UserId): User | undefined;
}
interface Clock {
now(): Date;
}Тепер UserService залежить від можливостей об’єкта, а не від його реалізації:
interface UserServiceDependencies {
readonly logger: Logger;
readonly repository: UserRepository;
readonly clock: Clock;
}
class UserService {
constructor(
private readonly dependencies: UserServiceDependencies,
) {}
register(name: string): User {
const user = this.dependencies.repository.save({
name,
createdAt: this.dependencies.clock.now(),
});
this.dependencies.logger.info("Користувача створено", {
userId: user.id,
});
return user;
}
}Тип UserServiceDependencies має кілька переваг:
усі залежності видно в одному місці;
TypeScript перевіряє повноту об’єкта;
залежності мають readonly, тому сервіс не може випадково підмінити їх;
конструктор залишається коротким, якщо залежностей стає більше.
Варіант із окремими параметрами також коректний:
class UserService {
constructor(
private readonly logger: Logger,
private readonly repository: UserRepository,
private readonly clock: Clock,
) {}
}Об’єкт залежностей зручніший, коли кількість залежностей зростає або потрібно передавати їх між кількома фабриками.
Контракт реалізується конкретними класами:
type UserId = string & {
readonly __brand: "UserId";
};
function createUserId(value: string): UserId {
return value as UserId;
}
interface NewUser {
readonly name: string;
readonly createdAt: Date;
}
interface User extends NewUser {
readonly id: UserId;
}
class ConsoleLogger implements Logger {
info(message: string, context: Record<string, unknown> = {}): void {
console.log(`[INFO] ${message}`, context);
}
error(message: string, context: Record<string, unknown> = {}): void {
console.error(`[ERROR] ${message}`, context);
}
}
class SystemClock implements Clock {
now(): Date {
return new Date();
}
}
class InMemoryUserRepository implements UserRepository {
private readonly users = new Map<UserId, User>();
private sequence = 0;
save(user: NewUser): User {
this.sequence += 1;
const savedUser: User = {
id: createUserId(`user-${this.sequence}`),
name: user.name,
createdAt: user.createdAt,
};
this.users.set(savedUser.id, savedUser);
return savedUser;
}
findById(id: UserId): User | undefined {
return this.users.get(id);
}
}implements перевіряє, що клас відповідає інтерфейсу:
class InMemoryUserRepository implements UserRepository {
// TypeScript вимагає реалізувати save та findById
}Важливо, що implements не змінює тип екземпляра під час виконання і не створює DI автоматично. Це лише статична перевірка контракту.
Місце, де створюються конкретні залежності й з’єднуються компоненти, називають composition root.
Зазвичай це точка входу застосунку:
const productionDependencies = {
logger: new ConsoleLogger(),
repository: new InMemoryUserRepository(),
clock: new SystemClock(),
} satisfies UserServiceDependencies;
const userService = new UserService(productionDependencies);
const user = userService.register("Олена");
console.log(user);Оператор satisfies перевіряє форму об’єкта, але не змінює його виведений тип.
На відміну від явного приведення:
const dependencies =
value as UserServiceDependencies;satisfies не приховує помилку в об’єкті. Якщо пропустити залежність або передати несумісний тип, TypeScript повідомить про це.
Наприклад, цей код не скомпілюється:
const invalidDependencies = {
logger: new ConsoleLogger(),
repository: new InMemoryUserRepository(),
// clock відсутній
} satisfies UserServiceDependencies;У TypeScript конструктор класу також має тип. Тип конструктора описує:
параметри конструктора;
тип екземпляра, який він повертає.
type Constructor<Instance, Arguments extends unknown[]> =
new (...args: Arguments) => Instance;Наприклад, конструктор UserService можна описати так:
type UserServiceConstructor = Constructor<
UserService,
[UserServiceDependencies]
>;Універсальна функція для створення екземплярів може приймати типізований конструктор:
function construct<Instance, Arguments extends unknown[]>(
Class: Constructor<Instance, Arguments>,
...argumentsList: Arguments
): Instance {
return new Class(...argumentsList);
}TypeScript виведе типи автоматично:
const service = construct(
UserService,
productionDependencies,
);Якщо передати неправильну залежність, помилка виникне в місці створення:
const wrongRepository = {
save: (user: NewUser): User => ({
id: createUserId("wrong"),
name: user.name,
createdAt: user.createdAt,
}),
};
// Помилка: відсутній метод findById
const invalidService = construct(UserService, {
logger: new ConsoleLogger(),
repository: wrongRepository,
clock: new SystemClock(),
});Це корисно для фабрик і власних DI-механізмів: фабрика типізується через конструктор і не втрачає інформацію про параметри.
Наведений код можна зберегти у файл index.ts і скомпілювати в режимі strict:
type UserId = string & {
readonly __brand: "UserId";
};
function createUserId(value: string): UserId {
return value as UserId;
}
interface NewUser {
readonly name: string;
readonly createdAt: Date;
}
interface User extends NewUser {
readonly id: UserId;
}
interface Logger {
info(message: string, context?: Record<string, unknown>): void;
error(message: string, context?: Record<string, unknown>): void;
}
interface UserRepository {
save(user: NewUser): User;
findById(id: UserId): User | undefined;
}
interface Clock {
now(): Date;
}
class ConsoleLogger implements Logger {
info(message: string, context: Record<string, unknown> = {}): void {
console.log(`[INFO] ${message}`, context);
}
error(message: string, context: Record<string, unknown> = {}): void {
console.error(`[ERROR] ${message}`, context);
}
}
class FixedClock implements Clock {
constructor(private readonly date: Date) {}
now(): Date {
return this.date;
}
}
class InMemoryUserRepository implements UserRepository {
private readonly users = new Map<UserId, User>();
private sequence = 0;
save(user: NewUser): User {
this.sequence += 1;
const savedUser: User = {
id: createUserId(`user-${this.sequence}`),
name: user.name,
createdAt: user.createdAt,
};
this.users.set(savedUser.id, savedUser);
return savedUser;
}
findById(id: UserId): User | undefined {
return this.users.get(id);
}
}
interface UserServiceDependencies {
readonly logger: Logger;
readonly repository: UserRepository;
readonly clock: Clock;
}
class UserService {
constructor(
private readonly dependencies: UserServiceDependencies,
) {}
register(name: string): User {
if (name.trim().length === 0) {
throw new Error("Ім’я користувача не може бути порожнім");
}
const user = this.dependencies.repository.save({
name,
createdAt: this.dependencies.clock.now(),
});
this.dependencies.logger.info("Користувача створено", {
userId: user.id,
});
return user;
}
getName(id: UserId): string | undefined {
return this.dependencies.repository.findById(id)?.name;
}
}
type Constructor<Instance, Arguments extends unknown[]> =
new (...args: Arguments) => Instance;
function construct<Instance, Arguments extends unknown[]>(
Class: Constructor<Instance, Arguments>,
...argumentsList: Arguments
): Instance {
return new Class(...argumentsList);
}
// Composition root для production-конфігурації.
const productionDependencies = {
logger: new ConsoleLogger(),
repository: new InMemoryUserRepository(),
clock: {
now: () => new Date(),
},
} satisfies UserServiceDependencies;
const userService = construct(
UserService,
productionDependencies,
);
const user = userService.register("Олена");
console.log(userService.getName(user.id));
// Тестова конфігурація з контрольованим часом і логером.
const messages: string[] = [];
const testDependencies = {
logger: {
info(message: string): void {
messages.push(message);
},
error(message: string): void {
messages.push(message);
},
},
repository: new InMemoryUserRepository(),
clock: new FixedClock(new Date("2025-01-01T00:00:00.000Z")),
} satisfies UserServiceDependencies;
const testService = new UserService(testDependencies);
const testUser = testService.register("Тестовий користувач");
if (testUser.createdAt.toISOString() !== "2025-01-01T00:00:00.000Z") {
throw new Error("Час створення користувача визначено неправильно");
}
if (messages[0] !== "Користувача створено") {
throw new Error("Логер не отримав повідомлення");
}
console.log("Перевірки виконано успішно");У цьому прикладі:
UserService знає лише контракти Logger, UserRepository і Clock;
InMemoryUserRepository, ConsoleLogger і FixedClock є деталями реалізації;
production- та test-конфігурації мають однаковий тип;
час можна повністю контролювати в тесті;
логер можна замінити об’єктом, який накопичує повідомлення;
TypeScript перевіряє сумісність усіх компонентів під час компіляції.
TypeScript використовує структурну типізацію. Об’єкт не зобов’язаний явно реалізовувати інтерфейс через implements. Достатньо, щоб він мав потрібну структуру.
const simpleLogger = {
info(message: string): void {
console.log(message);
},
error(message: string): void {
console.error(message);
},
};
const dependencies = {
logger: simpleLogger,
repository: new InMemoryUserRepository(),
clock: new SystemClock(),
} satisfies UserServiceDependencies;simpleLogger сумісний із Logger, хоча не оголошений як екземпляр класу ConsoleLogger.
Це особливо зручно для:
тестових заглушок;
невеликих адаптерів;
об’єктів, створених фабриками;
інтеграції з кодом, який не контролює TypeScript-класи.
Водночас інтерфейс перевіряє лише форму. Він не гарантує правильність поведінки методу. Наприклад, метод save може мати правильний тип, але помилково не зберігати дані. Такі властивості перевіряються тестами.
Залежність варто передавати в конструктор, якщо вона потрібна кількома методами або визначає поведінку всього класу:
class UserService {
constructor(
private readonly repository: UserRepository,
) {}
create(): User {
return this.repository.save({
name: "Користувач",
createdAt: new Date(),
});
}
find(id: UserId): User | undefined {
return this.repository.findById(id);
}
}Якщо об’єкт потрібен лише одній операції, його можна передати як параметр методу:
interface UserExporter {
export(user: User): string;
}
class UserReportService {
createReport(user: User, exporter: UserExporter): string {
return exporter.export(user);
}
}Не кожен параметр методу потрібно перетворювати на залежність конструктора. DI має відображати стабільні залежності компонента, а не всі значення, з якими він працює.
Циклічна залежність виникає, коли:
ServiceA залежить від ServiceB;
ServiceB залежить від ServiceA.
Наприклад:
class ServiceA {
constructor(private readonly serviceB: ServiceB) {}
}
class ServiceB {
constructor(private readonly serviceA: ServiceA) {}
}Створити такі об’єкти напряму складно:
// Неможливо завершити створення жодного з об’єктів.Зазвичай проблему вирішують зміною відповідальностей:
винести спільну логіку в третій компонент;
залежати від менших інтерфейсів;
передавати результат операції, а не весь сервіс;
підняти координацію на вищий рівень.
DI робить такі цикли помітними: залежності явно записані в конструкторах, а не приховані всередині класів.
Інтерфейси TypeScript існують лише під час компіляції. Після компіляції вони видаляються з JavaScript:
interface Logger {
info(message: string): void;
}Тому JavaScript-контейнер не може використовувати Logger як ключ під час виконання. Для автоматичного контейнера потрібен runtime-токен, наприклад клас або symbol:
const LOGGER_TOKEN = Symbol("Logger");
type LoggerToken = typeof LOGGER_TOKEN;
const loggerRegistry = new Map<LoggerToken, Logger>();
loggerRegistry.set(LOGGER_TOKEN, new ConsoleLogger());
const logger = loggerRegistry.get(LOGGER_TOKEN);
if (!logger) {
throw new Error("Логер не зареєстровано");
}Однак для невеликих застосунків ручне складання залежностей часто є простішим і прозорішим:
const service = new UserService({
logger: new ConsoleLogger(),
repository: new InMemoryUserRepository(),
clock: new SystemClock(),
});Автоматичний контейнер не замінює типізацію компонентів. Він лише автоматизує їх створення та пошук. Контракти залежностей усе одно мають бути описані типами.
class UserService {
private readonly repository = new InMemoryUserRepository();
}Це усуває можливість легко замінити реалізацію. Краще передавати залежність через конструктор.
class UserService {
constructor(
private readonly repository: InMemoryUserRepository,
) {}
}Якщо сервіс використовує лише методи UserRepository, краще типізувати параметр інтерфейсом:
class UserService {
constructor(
private readonly repository: UserRepository,
) {}
}const dependencies =
unknownValue as UserServiceDependencies;Так TypeScript перестає перевіряти реальну структуру об’єкта. Для відомих об’єктів краще використовувати satisfies.
class UserService {
constructor(
private dependencies: UserServiceDependencies,
) {}
replaceRepository(repository: UserRepository): void {
this.dependencies.repository = repository;
}
}Якщо підміна не є частиною API класу, залежності мають бути незмінними:
class UserService {
constructor(
private readonly dependencies: UserServiceDependencies,
) {}
}Глобальний контейнер приховує залежності:
class UserService {
create(): void {
const repository = globalContainer.get("repository");
}
}За сигнатурою конструктора неможливо зрозуміти, що потрібно сервісу. Крім того, тести починають залежати від глобального стану. Краще передавати залежності явно.
any у власному DI-кодіfunction construct(Class: any, ...args: any[]): any {
return new Class(...args);
}Такий код знищує перевірку аргументів і результату. Для конструкторів потрібно використовувати узагальнені типи:
type Constructor<Instance, Arguments extends unknown[]> =
new (...args: Arguments) => Instance;
function construct<Instance, Arguments extends unknown[]>(
Class: Constructor<Instance, Arguments>,
...argumentsList: Arguments
): Instance {
return new Class(...argumentsList);
}Dependency injection передає залежності об’єкту ззовні.
Найпростіший і найнадійніший варіант — constructor injection.
Залежності краще описувати інтерфейсами або вузькими контрактами.
Поля залежностей варто робити private readonly.
implements перевіряє відповідність класу контракту, але не створює DI.
satisfies перевіряє конфігурацію залежностей без небезпечного приведення типу.
Composition root відповідає за створення й з’єднання конкретних реалізацій.
Тип конструктора new (...args) => Instance дає змогу безпечно створювати узагальнені фабрики.
Інтерфейси не існують під час виконання, тому автоматичним контейнерам для них потрібні runtime-токени.
Явні залежності спрощують тестування та зменшують зв’язність між класами.