javascript

DI в TypeScript без декораторов: почему это будущее

  • вторник, 2 июня 2026 г. в 00:00:05
https://habr.com/ru/articles/1042290/

Если вы пишете на TypeScript больше пары лет, то наверняка привыкли к классическому паттерну внедрения зависимостей. Вы создаете класс, помечаете его декоратором @Injectable(), прописываете токеновые декораторы в параметрах конструктора и включаете emitDecoratorMetadata в tsconfig.json. После этого фреймворк берет всю магию на себя.

Для 2015 года, когда декораторы только появились, это было отличным решением. Однако сегодняшний TypeScript ушел далеко вперед, превратившись в мощный инструмент с Conditional Types и продвинутым выводом типов. На этом фоне популярные DI-решения выглядят застрявшими в прошлом. Пока вся остальная экосистема летит вперед, старые подходы к внедрению зависимостей превращаются в балласт, который лишает нас преимуществ современной типизации и откровенно тормозит развитие проектов.

Именно эта проблема подтолкнула меня к созданию InferDI — первого DI-контейнера для TypeScript, который полностью меняет правила игры. Вместо использования декораторов, reflect-metadata, трансформеров или тяжелой кодогенерации, он переносит не только типы сервисов, но и сам граф зависимостей вместе с lifetime-правилами напрямую в систему типов TypeScript.

Конечно, в экосистеме есть проекты, пытающиеся решить часть этих задач: они отказываются от декораторов, типизируют токены или делают регистрацию чуть безопаснее. Но InferDI впервые собирает эти разрозненные фичи в единую монолитную модель. Благодаря этому компилятор видит не отдельные фрагменты, а весь граф целиком: он знает, какие ключи существуют, какие типы за ними стоят, какие зависимости требуются конструктору и какие lifetime-связи запрещены вашей архитектурой.

Из этого вытекает главный принцип InferDI: граф зависимостей сам по себе является типом. Это уже не просто очередной контейнер без декораторов. Это инструмент с радикально иной философией: если граф некорректен, программа не должна падать в runtime — она просто не должна скомпилироваться.

Проведя серию бенчмарков и сравнив свое решение с InversifyJS, TSyringe, TypeDI и Awilix, я пришел к однозначному выводу: будущее DI в TypeScript — это полный отказ от декораторов и рантайм-магии.

В этой статье мы не будем детально разбирать графики микросекунд, хотя InferDI уже сейчас лидирует в бенчмарках. Мы поговорим о более фундаментальных вещах: почему отказ от «удобных» аннотаций в пользу строгих типов — это лучшая инвестиция в архитектуру, безопасность и долговечность вашего проекта.

Проблема №1: Инфраструктурная хрупкость (и почему компилируемые DI — не панацея)

Экосистема сейчас невероятно турбулентна. Мы массово отказываемся от медленного tsc и Webpack в пользу сверхбыстрых инструментов на Rust/Go (esbuild, SWC), переходим на Vite, используем Bun, Deno и Node с нативной поддержкой TypeScript.

На этом фоне у традиционных DI-решений есть три пути, и все три — тупиковые:

  1. Legacy decorators + Reflection API. Декораторы Stage 2 с флагами experimentalDecorators и emitDecoratorMetadata — классический подход NestJS, TSyringe и InversifyJS. Чтобы автосвязывание работало, сборщик должен зашить реальный тип каждого параметра конструктора в Reflect.metadata — а для этого нужен полноценный type checker, знающий всё про дженерики, интерфейсы и алиасы. Быстрые транспайлеры этот шаг принципиально пропускают: esbuild прямо документирует emitDecoratorMetadata как unsupported feature и не реализует его вовсе, а SWC поддерживает флаг лишь частично, эмитируя для дженериков и интерфейсов безликие Object или undefined. Итог: используя Reflection-DI, вы навсегда заперты в рамках медленного tsc без возможности уйти на современные быстрые тулчейны.

  2. Stage 3 / TC39 decorators (новый стандарт). Самый частый контраргумент: «подождём, пока стандартные декораторы стабилизируются, и всё заработает везде». Не заработает. Спецификация TC39 Stage 3 определяет декораторы только для классов, методов, полей, геттеров, сеттеров и авто-аксессоров — параметрические декораторы (parameter decorators) в стандарте отсутствуют как класс. Аналога @Inject(...) для параметров конструктора в новом стандарте просто нет и не планируется. Сам объект context, который получает декоратор, не содержит метаданных о типах — только мета-информацию вроде kind и name. Полноценный Reflection-DI на TC39-декораторах невозможен в принципе, даже при их идеальной поддержке во всех транспайлерах.

  3. Compile-time DI. Инструменты, которые анализируют AST на этапе компиляции и генерируют код связывания. Звучит здорово, пока вы не попытаетесь запустить это на практике. Такие решения требуют глубокой модификации компилятора (через ts-patch или кастомные трансформеры). Захотели перенести проект на Bun? Или собрать через SWC в Next.js? Ваш DI моментально сломается, потому что сторонние сборщики ничего не знают о ваших кастомных плагинах для tsc.

Решение InferDI: 100% ванильный TypeScript

InferDI не требует ничего. Ни декораторов, ни плагинов, ни патчей компилятора. Он опирается исключительно на мощь современной системы вывода типов (Type Inference) самого TypeScript.

import { Container } from '@inferdi/inferdi'

// Работает везде: Node, Bun, Deno, браузер, Vite, SWC. 
// Никаких настроек в tsconfig.json не нужно!
const container = new Container()
  .registerValue('dsn', 'postgres://localhost/db')
  .registerClass('db', Database, ['dsn'])

Это инвестиция в стабильность. Независимо от того, какой модный сборщик или рантайм захватит индустрию в ближайшие годы, ваш DI-граф гарантированно заведется там с пол-оборота.

Проблема №2: Заражение бизнес-логики (Vendor Lock-in)

Откройте типичный проект на популярном DI-фреймворке. Что вы увидите в файлах с доменной логикой?

// ❌ Нарушение чистой архитектуры и жесткая привязка к фреймворку
import { Injectable, Inject } from 'some-legacy-di';
import { ILogger } from './interfaces';

@Injectable()
export class UserService {
  constructor(
    @Inject('ILogger') private logger: ILogger,
  ) {}
}

Ваш бизнес-слой больше не принадлежит вам — он намертво пронизан импортами сторонней библиотеки. Если через какое-то время фреймворк перестанет поддерживаться или вы решите сменить его, вам придется вручную вырезать эти декораторы и импорты из сотен, а то и тысяч файлов по всему проекту.

Решение InferDI: Принудительная архитектурная гигиена

С InferDI ваши компоненты остаются чистыми классами (POJO / Plain JavaScript Objects). Они полностью изолированы от инфраструктурного слоя и ничего не знают о том, кто, где, как и в каком контексте их создает

// ✅ Чистая архитектура: класс вообще не знает про существование DI
import { Logger } from './logger'

export class UserService {
  // Обычный конструктор на ванильном TypeScript
  constructor(private logger: Logger, private apiKey: string) {}
}

Вся регистрация и связывание происходят в одном единственном месте — Composition Root (файле конфигурации контейнера). Да, это требует явного описания зависимостей в одном файле. Но взамен вы получаете абсолютную свободу. Если в будущем вы решите выкинуть InferDI, вам потребуется изменить ровно один конфигурационный файл, а вся бизнес-логика останется абсолютно нетронутой.

Проблема №3: Магия автосвязывания vs Строгость компилятора

Главный аргумент сторонников декораторов звучит так: "Мне не нужно вручную прописывать зависимости, фреймворк сам найдет их по типам в конструкторе!".

Это магия. А магия хороша только в туториалах. В крупных реальных проектах неявное поведение превращается в часы бесплодного дебага: то вместо ожидаемого синглтона прилетает новый инстанс, то зависимость вообще отказывается резолвиться из-за циклической ссылки. При этом stack trace упирается глубоко во внутренности фреймворка, поведение приложения начинает зависеть от порядка импортов, а любая ошибка регистрации обнаруживается не на этапе сборки, а уже в рантайме (хорошо, если на стейджинге, а не на проде).

Но как сделать явное связывание безопасным? Как гарантировать, что разработчик не опечатается при передаче зависимостей?

Решение InferDI: Экстремальная типизация

Философия InferDI проста: явное лучше неявного. Магия экономит вам пять строк кода, но взамен лишает контроля над логикой разрешения зависимостей, понятной отладки и, главное, проверки графа на этапе компиляции.

В InferDI массив зависимостей строго валидируется компилятором TypeScript сразу по трем осям:

  1. Типы аргументов конструктора.

  2. Их точная позиция в конструкторе.

  3. Допустимость lifetime-связей (singleton / scoped / transient) для каждого компонента.

Несовпадение хотя бы по одному критерию — это моментальная ошибка компиляции, а не сюрприз после деплоя.

class Logger {}
class UserService {
  // Конструктор ждет: (Logger, string)
  constructor(private logger: Logger, private apiKey: string) {}
}

const c = new Container()
  .registerValue('apiKey', 'secret-123')
  .registerClass('logger', Logger, [])

  // ❌ ОШИБКА КОМПИЛЯЦИИ! 
  // TS видит, что 'apiKey' возвращает строку, а первым аргументом требуется Logger
  .registerClass('userService', UserService, ['apiKey', 'logger'])

  // ✅ ИДЕАЛЬНО! Порядок и типы совпадают.
  .registerClass('userService', UserService, ['logger', 'apiKey'])

// Тип инстанса выводится автоматически, никаких `as UserService`!
const service = c.get('userService')

Если вы измените сигнатуру конструктора UserService — добавите аргумент, удалите его или поменяете параметры местами — TypeScript моментально подсветит строку с .registerClass() красным. Ваше приложение никогда не упадет в рантайме из-за того, что DI передал не тот класс или перепутал аргументы. Место хрупкой магии занимает жесткая и предсказуемая математика типов.

Это касается не только типов аргументов, но и времени жизни зависимостей (lifetime). В InferDI доступны три стандартные стратегии:

  • singleton (один экземпляр на контейнер),

  • scoped (один экземпляр на область видимости — например, на HTTP-запрос),

  • transient (новый экземпляр на каждый вызов get()).

Классическая архитектурная ловушка в больших проектах — внедрить короткоживущую зависимость в долгоживущую. Стоит положить scoped-репозиторий в singleton-сервис, как репозиторий вместе со всем контекстом запроса намертво застревает в памяти до конца процесса. В традиционных DI-контейнерах такие утечки памяти обнаруживаются только под нагрузкой на проде. В InferDI это отлавливается на этапе статической проверки:

class RequestContext {}
class Metrics {
  constructor(private ctx: RequestContext) {}
}

new Container()
  .registerClass('ctx', RequestContext, [], 'scoped')

  // ❌ ОШИБКА КОМПИЛЯЦИИ!
  // 'ctx' живет в скоупе, а 'metrics' — синглтон
  // Внедрение scoped-зависимости в singleton запрещено, так как ведет к утечке памяти
  .registerClass('metrics', Metrics, ['ctx'], 'singleton')

Но что делать, если двум синглтонам действительно нужно ссылаться друг на друга (классический циклический граф A ↔ B)? Для этого у InferDI есть встроенный компаньон Lazy<T>. Цикл разрешается передачей дополнительного аргумента: registerClass(..., { lazy: true }).

При этом тип LazySpec<V, 'singleton'> гарантирует, что в синглтон можно внедрить только Lazy<singleton>. Любая попытка передать туда Lazy<scoped> или Lazy<transient> будет отвергнута компилятором.

Даже если разработчик попытается обойти тайп-чекер с помощью грязного хака вроде as any в массиве зависимостей, сработает концепция эшелонированной обороны (defense-in-depth). В методе get() активируется страховочный runtime-guard, который выбросит понятное исключение о нарушении lifetime-правил еще при запуске приложения — задолго до того, как скрытая утечка обрушит прод под реальной нагрузкой.

Бонус: тип контейнера — это и есть форма графа

InferDI идет гораздо дальше простой проверки массива зависимостей. Тип собранного контейнера представляет собой полную статическую модель вашего DI-графа. Утилитарный тип Container.Resolve<C> позволяет извлечь её в виде обычного объектного типа:

const container = new Container()
  .registerValue('apiKey', 'secret-123')
  .registerClass('logger', Logger, [])
  .registerClass('userService', UserService, ['logger', 'apiKey'])

type AppDeps = Container.Resolve<typeof container>
// ^? { apiKey: string; logger: Logger; userService: UserService }

Никакой кодогенерации и никаких искусственных .d.ts-артефактов, которые приходится синхронизировать с конфигом сборщика. Граф зависимостей — это чистый тип. Вы можете передавать его как архитектурный контракт между слоями системы, использовать как источник keyof для написания юнит-тестов или безопасно мокать ключи с полноценным автокомплитом в IDE. Всё, что вы привыкли делать с обычными TypeScript-типами, теперь применимо и к описанию всей структуры вашего приложения.

Итог: смена парадигмы

Отказ от декораторов — это не дань моде и не усложнение ради усложнения. Это осознанный инженерный выбор в пользу надежности.

Выбирая подход InferDI, вы получаете:

  1. Полную независимость от инфраструктуры сборки: код гарантированно работает в любом окружении, от Node.js до Bun, Deno и Vite.

  2. Изоляцию бизнес-логики: доменные классы абсолютно чисты, изолированы от инфраструктурных решений и не содержат внешних импортов.

  3. Статическую валидацию графа: компилятор TypeScript полностью контролирует разрешение зависимостей, исключая ошибки в рантайме.

Безусловно, явное объявление ключей в конфигурационном файле требует начальных усилий. Однако эти минимальные затраты полностью компенсируются надежностью кодовой базы, безопасностью рефакторинга и простотой долгосрочной поддержки проекта.

В следующей статье мы заглянем «под капот» InferDI и детально разберем результаты бенчмарков. Мы сравним его производительность с InversifyJS, TSyringe, TypeDI и Awilix. Вы увидите, как полный отказ от Reflection API и Proxy позволяет InferDI работать до 48 раз быстрее конкурентов при сборке графа и до 2 раз быстрее на горячем пути (hot path).

🌟 Готовы попробовать?

Исходный код, документация и бенчмарки уже ждут вас на GitHub: https://github.com/inferdi/inferdi

Буду рад ответить на любые вопросы и сомнения в комментариях! Как вы относитесь к переходу от магии декораторов к строгой типизации?