I've been building a dependency injection container for Node/TypeScript called Injectus, and I'm at the point where I'd rather have a handful of people actually try it and poke holes in it than keep polishing alone. It's 0.2.x not "adopt this in prod tomorrow" young, but the core is solid and I want real usage before I push it further.
What it is: zero-dependency, decorator-free IoC container. No reflect-metadata, no emitDecoratorMetadata, no build config wrestling.
import { Injector, inject, InjectionToken } from "injectus";
const DB_URL = new InjectionToken<string>("DB_URL");
class Database {
url = inject(DB_URL);
}
class UserService {
db = inject(Database);
findAll() {
return this.db.url;
}
}
const injector = Injector.create({
providers: [
{ provide: DB_URL, useValue: "postgres://localhost/app" },
Database,
UserService,
],
});
injector.resolve(UserService).findAll();
await injector.dispose();
Why I built it: I kept hitting the same friction with existing containers - decorators requiring specific tsconfig/build setups, string or symbol tokens silently colliding, and singleton/scoped lifetime mismatches only surfacing as bugs in production. Injectus's actual differentiators:
-
Decorator-free -
inject()works in field initializers and factory bodies, no metadata reflection required. -
Identity-based tokens - classes and
InjectionTokeninstances are the key, not their string description. Two tokens named"Logger"can't collide. -
Captive dependency detection - resolving a
Singletonthat depends on aScopedbinding throwsCaptiveDependencyErrorat resolve time, not silently in prod. -
Deterministic disposal - LIFO teardown via
Symbol.asyncDispose, multiple failing disposers collected into oneAggregateError, full dependency path exposed root-to-leaf onCircularDependencyError/CaptiveDependencyError.
It also benchmarks well against the two closest points of comparison - awilix (decorator-free) and tsyringe (decorator-based) - resolving the same dependency-graph shape in each case:
What's honestly not done yet, so you know what you're getting into:
- No module system yet - (flat provider arrays only, for now)
- No multi-injection - registering the same token twice currently shadows (last-write-wins)
- No async init hooks / lifecycle ordering yet
- No framework adapters (Express example exists, others don't)
Top comments (3)
Here is the GitHub repository link for anyone interested in inspecting the code or trying it out:
github.com/hossam7amdy/injectus
You asked for holes, so here's the first place I'd push. With
inject()running inside a field initializer and no decorators or metadata, how does it know which injector it belongs to at that moment? My guess is there's an ambient "current resolution" context alive whileinjector.resolve()runs. If that's the trick, what happens when someone builds the class directly withnew UserService()outside a resolve call, or lazily inside an async callback after that context has unwound? That edge is where designs like this usually get interesting, so it'd be worth a line in the README so nobody trips over it. The captive dependency check throwing at resolve time is a really nice touch, by the way.Good catch, you nailed the mechanism. There's a module-global
currentContextthatresolve()sets around the factory/field initializer and restores infinally, same trick Angular'sinject()uses, borrowed directly from there.Direct
new UserService()outsideresolve(), orinject()afterawait/setTimeout, throwsInjectionContextError.For manual construction, the same idea as constructor params: