Если вы пишете на 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-решений есть три пути, и все три — тупиковые:
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без возможности уйти на современные быстрые тулчейны.Stage 3 / TC39 decorators (новый стандарт). Самый частый контраргумент: «подождём, пока стандартные декораторы стабилизируются, и всё заработает везде». Не заработает. Спецификация TC39 Stage 3 определяет декораторы только для классов, методов, полей, геттеров, сеттеров и авто-аксессоров — параметрические декораторы (parameter decorators) в стандарте отсутствуют как класс. Аналога
@Inject(...)для параметров конструктора в новом стандарте просто нет и не планируется. Сам объектcontext, который получает декоратор, не содержит метаданных о типах — только мета-информацию вродеkindиname. Полноценный Reflection-DI на TC39-декораторах невозможен в принципе, даже при их идеальной поддержке во всех транспайлерах.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 сразу по трем осям:
Типы аргументов конструктора.
Их точная позиция в конструкторе.
Допустимость 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, вы получаете:
Полную независимость от инфраструктуры сборки: код гарантированно работает в любом окружении, от Node.js до Bun, Deno и Vite.
Изоляцию бизнес-логики: доменные классы абсолютно чисты, изолированы от инфраструктурных решений и не содержат внешних импортов.
Статическую валидацию графа: компилятор TypeScript полностью контролирует разрешение зависимостей, исключая ошибки в рантайме.
Безусловно, явное объявление ключей в конфигурационном файле требует начальных усилий. Однако эти минимальные затраты полностью компенсируются надежностью кодовой базы, безопасностью рефакторинга и простотой долгосрочной поддержки проекта.
В следующей статье мы заглянем «под капот» InferDI и детально разберем результаты бенчмарков. Мы сравним его производительность с InversifyJS, TSyringe, TypeDI и Awilix. Вы увидите, как полный отказ от Reflection API и Proxy позволяет InferDI работать до 48 раз быстрее конкурентов при сборке графа и до 2 раз быстрее на горячем пути (hot path).
? Готовы попробовать?
Исходный код, документация и бенчмарки уже ждут вас на GitHub: https://github.com/inferdi/inferdi
Буду рад ответить на любые вопросы и сомнения в комментариях! Как вы относитесь к переходу от магии декораторов к строгой типизации?
Комментарии (11)

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

maxrendel Автор
01.06.2026 14:55Нет, раньше не встречал
@tinkoff/dippy, спасибо за пример. Посмотрел — интересная реализация.Но как раз в рамках статьи я говорю о другом ожидании от DI в 2026 году. TypeScript сегодня может намного больше, чем просто типизировать отдельный token или provider. Поэтому современный DI-контейнер, на мой взгляд, должен использовать всю мощь TypeScript, чтобы давать максимум статических гарантий ещё до запуска приложения.
DI не должен добавлять новые источники ошибок: незарегистрированные зависимости, ошибки в списке constructor-зависимостей, случайные
singleton→scoped/requestзависимости. Напротив, он должен помогать сводить такие проблемы к минимуму на этапе компиляции.В этом смысле Dippy не позволяет компилятору проверить корректность всего DI-графа до запуска приложения.

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 нечем } }данные текут только в одну сторону, от долгоживущего корня к короткоживущему наследнику, но не обратно.

maxrendel Автор
01.06.2026 14:55Да, в
$molдействительно сильный архитектурный приём: проблема решается не дополнительными проверками DI-графа, а самой моделью контекста.Но у этого подхода есть цена: нужно принять целую парадигму
$mol: ambient context, реактивные мемоизированные свойства и жизненный цикл объектов через дерево зависимостей. То есть приложение должно быть написано в этой модели.Поэтому я бы разделил эти подходы так:
$molделает такую ошибку невыразимой через архитектуру всего приложения, а InferDI через тип DI-графа внутри обычной TypeScript-архитектуры. Это разные уровни решения одной и той же проблемы.

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

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').

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

maxrendel Автор
01.06.2026 14:55Аргумент «все ai вам скажут что di это зло» не является техническим. Если безоговорочно принимать ответы LLM за архитектурные решения, это превращает работу с AI скорее в «религию», чем в инженерный анализ.
На мой взгляд, AI полезен не для перекладывания ответственности за результат, а для поиска вариантов, сравнения компромиссов и проверки решений. Финальное решение всё равно должно опираться на понимание предметной области, требований и рисков.
Тем более в AI-first приложениях зависимости никуда не исчезают. Отказ от DI просто переносит их в глобальные imports, скрытые singleton’ы, service locator, неявное состояние и runtime инициализацию. Архитектура от этого не становится чище.
Поэтому «di это зло» слишком общее утверждение. Плохой DI — зло. Собственно, значительная часть статьи как раз об этом.
flancer
Положительно отношусь. Даже запилил свой DI-контейнер под чистый JS - https://github.com/teqfw/di
Правда, тут JSDoc вместо "строгой типизации", но для навигации по коду этого хватает, а в runtime JSDoc не используется точно так же, как и "строгая типизация" TypeScript. Зато одинаково хорошо работает и для фронта, и для бэка.
Есть ещё масса нетрадиционных тупиковых путей. Надо только покопать.
Я, кстати, у себя вообще отказался от
container.registerXXX, оставил только для тестового режима. Всё остальное "на магии", как вы говорите - через настройку маппинга пространства имён на пути в файловой системе (калька с Java с их classpath и PHP с PSR-4).Зато такой подход даёт возможность вообще отказаться от статических импортов (они есть только в Composition Root) и добавить в приложения такие экзотические для JavaScript вещи, как
interface.То, что это, кроме меня, нафиг никому не надо в JS - это отдельный вопрос. Но у меня есть :)
maxrendel Автор
Посмотрел
teqfw/di- спасибо интересный подход.Согласен с вами в одном важном моменте: в runtime TypeScript типы действительно так же отсутствуют, как и JSDoc, но для InferDI ключевая ценность не в runtime, а в том, что TypeScript успевает проверить граф до запуска приложения и запретить некорректный граф на этапе компиляции: неправильный порядок зависимостей, отсутствующий ключ, несовпадение constructor signature,
singleton → scoped/transientи т.д.Ваш подход, насколько я понял, делает ставку на runtime-конвенции и модульный resolver. Это сильная идея для pure JS. InferDI же сознательно идёт в другую сторону, только явный Composition Root, потому что именно он становится типом графа.