Если вы пишете на 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

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

Комментарии (11)


  1. flancer
    01.06.2026 14:55

    Как вы относитесь к переходу от магии декораторов к строгой типизации?

    Положительно отношусь. Даже запилил свой DI-контейнер под чистый JS - https://github.com/teqfw/di

    Правда, тут JSDoc вместо "строгой типизации", но для навигации по коду этого хватает, а в runtime JSDoc не используется точно так же, как и "строгая типизация" TypeScript. Зато одинаково хорошо работает и для фронта, и для бэка.

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

    Есть ещё масса нетрадиционных тупиковых путей. Надо только покопать.

    Я, кстати, у себя вообще отказался от container.registerXXX , оставил только для тестового режима. Всё остальное "на магии", как вы говорите - через настройку маппинга пространства имён на пути в файловой системе (калька с Java с их classpath и PHP с PSR-4).

    Зато такой подход даёт возможность вообще отказаться от статических импортов (они есть только в Composition Root) и добавить в приложения такие экзотические для JavaScript вещи, как interface.

    То, что это, кроме меня, нафиг никому не надо в JS - это отдельный вопрос. Но у меня есть :)


    1. maxrendel Автор
      01.06.2026 14:55

      Посмотрел teqfw/di - спасибо интересный подход.

      Согласен с вами в одном важном моменте: в runtime TypeScript типы действительно так же отсутствуют, как и JSDoc, но для InferDI ключевая ценность не в runtime, а в том, что TypeScript успевает проверить граф до запуска приложения и запретить некорректный граф на этапе компиляции: неправильный порядок зависимостей, отсутствующий ключ, несовпадение constructor signature, singleton → scoped/transient и т.д.

      Ваш подход, насколько я понял, делает ставку на runtime-конвенции и модульный resolver. Это сильная идея для pure JS. InferDI же сознательно идёт в другую сторону, только явный Composition Root, потому что именно он становится типом графа.


  1. MinskLeo
    01.06.2026 14:55

    Есть интересный пример реализации DI системы - @tinkoff/dippy. Слышали о таком?


    1. maxrendel Автор
      01.06.2026 14:55

      Нет, раньше не встречал @tinkoff/dippy, спасибо за пример. Посмотрел — интересная реализация.

      Но как раз в рамках статьи я говорю о другом ожидании от DI в 2026 году. TypeScript сегодня может намного больше, чем просто типизировать отдельный token или provider. Поэтому современный DI-контейнер, на мой взгляд, должен использовать всю мощь TypeScript, чтобы давать максимум статических гарантий ещё до запуска приложения.

      DI не должен добавлять новые источники ошибок: незарегистрированные зависимости, ошибки в списке constructor-зависимостей, случайные singletonscoped/request зависимости. Напротив, он должен помогать сводить такие проблемы к минимуму на этапе компиляции.

      В этом смысле Dippy не позволяет компилятору проверить корректность всего DI-графа до запуска приложения.


  1. cmyser
    01.06.2026 14:55

    Все игнорируют слона в комнате

    Вы решаете проблему доп проверками, валидациями

    В $mol объект существует ровно пока от него кто-то зависит

    Т.е. проблема 3 у вас невыразима в компиляции, а у $mol невыразима в принципе. Нельзя написать такой ошибочный код

    namespace $ {
      // singleton: на корневом $, один на процесс
      export class $my_metrics extends $mol_object2 {
        @ $mol_mem static total() { return 0 }
      }
    
      function handle( req: Request ) {
        const $$ = Object.create( $ ) as typeof $
        $$.$my_request_user = () => req.user   // "scoped": живёт только в этом $$
    
        // ...вся работа по запросу идёт через $$...
    
        $.$my_metrics.total()                  // singleton читает из КОРНЯ $
        // req-данных на корневом $ нет → протащить их в singleton нечем
      }
    }

    данные текут только в одну сторону, от долгоживущего корня к короткоживущему наследнику, но не обратно.


    1. maxrendel Автор
      01.06.2026 14:55

      Да, в $mol действительно сильный архитектурный приём: проблема решается не дополнительными проверками DI-графа, а самой моделью контекста.

      Но у этого подхода есть цена: нужно принять целую парадигму $mol: ambient context, реактивные мемоизированные свойства и жизненный цикл объектов через дерево зависимостей. То есть приложение должно быть написано в этой модели.

      Поэтому я бы разделил эти подходы так: $mol делает такую ошибку невыразимой через архитектуру всего приложения, а InferDI через тип DI-графа внутри обычной TypeScript-архитектуры. Это разные уровни решения одной и той же проблемы.


      1. cmyser
        01.06.2026 14:55

        А я старался над комментарием...


  1. frostsumonner
    01.06.2026 14:55

    А вы видели как di реализован в последнем angular? service = inject(UserService) и это красиво. А вот ваши контейннеры выглядят неочень. К тому же не увидел в InferDi с инжектом интерфейсов?


    1. maxrendel Автор
      01.06.2026 14:55

      Да, service = inject(UserService) выглядит лаконично. Однако это именно тот компромисс, о котором я писал в разделе «Магия автосвязывания vs Строгость компилятора». Angular делает разрешение зависимостей неявным и переносит часть рисков в runtime. Если провайдер не зарегистрирован, токен указан неверно или inject() вызван вне контекста инъекции — вы узнаете об этом только в runtime.

      В InferDI фокус принципиально другой: приоритетом является не внешняя простота, а проверяемость DI-графа на этапе компиляции TypeScript. Да, зависимости нужно явно описывать в Composition Root и визуально это выглядит менее эффектно, но зато компилятор строго валидирует ключи, типы, порядок аргументов конструктора и lifetime-связи. Это осознанный инженерный выбор в пользу надежности.

      API Angular действительно изящнее в месте использования. Но InferDI предлагает другую эстетику — архитектурную надежность и статические гарантий до запуска приложения.

      По интерфейсам — в Angular тоже их нельзя использовать как DI token, потому что они исчезают после компиляции. Angular решает это через InjectionToken<T>, а InferDI через аналогичную регистрацию по Symbol('token').


  1. Tim02
    01.06.2026 14:55

    Будущее это Ai разработка. Если спросить у разных Ai построить архитектуру Ai first приложения и какие антипаттерны лучше не использовать то все ai вам скажут что di это зло. До свидания.


    1. maxrendel Автор
      01.06.2026 14:55

      Аргумент «все ai вам скажут что di это зло» не является техническим. Если безоговорочно принимать ответы LLM за архитектурные решения, это превращает работу с AI скорее в «религию», чем в инженерный анализ.

      На мой взгляд, AI полезен не для перекладывания ответственности за результат, а для поиска вариантов, сравнения компромиссов и проверки решений. Финальное решение всё равно должно опираться на понимание предметной области, требований и рисков.

      Тем более в AI-first приложениях зависимости никуда не исчезают. Отказ от DI просто переносит их в глобальные imports, скрытые singleton’ы, service locator, неявное состояние и runtime инициализацию. Архитектура от этого не становится чище.

      Поэтому «di это зло» слишком общее утверждение. Плохой DI — зло. Собственно, значительная часть статьи как раз об этом.