Build a Dependency Injection Container in Vanilla JavaScript<br>JavaScript Development Space
Follow Us<br>TelegramBskySubstackDailyDev
-->Search posts, tutorials, and more...Ctrl+K<br>Ctrl+K
Quick Access
All PostsBrowse our latest articles
How-to GuidesStep-by-step tutorials
Code SnippetsReusable code examples
Friday LinksWeekly curated content
No results found<br>Try different keywords or check the spelling
Dependency injection has been part of application architecture for years. In the JavaScript world, however, the discussion almost always ends up revolving around TypeScript, decorators, and reflect-metadata.
That raises an interesting question.
Can you build a practical DI container using nothing but modern JavaScript?
The answer is yes.
In this article, we’ll build a lightweight dependency injection container from scratch without TypeScript, decorators, or runtime reflection. Instead of trying to inspect constructor parameters at runtime, we’ll use JavaScript’s own strengths. Classes are first-class values. They can carry metadata, be used as tokens, and be created dynamically.
The result is a surprisingly clean design that supports singletons, scoped services, transient lifetimes, and dependency resolution while keeping the domain layer completely independent from the container.
The implementation shown here is an experiment rather than a production-ready framework, but it demonstrates that JavaScript already provides everything needed for an elegant DI solution.
Why Constructor Inspection Doesn’t Work
Most dependency injection frameworks try to determine what a class depends on by analyzing its constructor.
Consider this service:
javascript
CopyCopied!<br>class CartApplicationService {<br>constructor(repository, catalog, discountPolicy) {<br>this.repository = repository;<br>this.catalog = catalog;<br>this.discountPolicy = discountPolicy;
At runtime, JavaScript knows only one thing.
This constructor accepts three arguments.
It has no idea that repository should implement a shopping cart repository, that catalog represents a product catalog, or that discountPolicy contains business rules for calculating discounts.
Parameter names are not contracts. After minification they often become a, b, and c. Default parameters, destructuring, and rest syntax make runtime analysis even less reliable.
Many frameworks solve this problem by generating metadata through decorators or TypeScript compilation.
Without those tools, the container has no reliable way to discover dependencies automatically.
A Different Approach
Instead of asking the container to guess dependencies later, we can declare them when the component is created.
Because JavaScript classes are regular runtime values, they can store their own metadata.
Imagine an API like this:
class {<br>constructor({ carts, catalog, discounts }) {<br>this.carts = carts;<br>this.catalog = catalog;<br>this.discounts = discounts;<br>);" data-astro-cid-5zfle2qk>javascript
CopyCopied!<br>const CartApplicationService = component(<br>name: "CartApplicationService",<br>lifetime: "scoped",<br>inject: {<br>carts: CART_REPOSITORY,<br>catalog: PRODUCT_CATALOG,<br>discounts: DISCOUNT_POLICY,<br>},<br>},<br>() =><br>class {<br>constructor({ carts, catalog, discounts }) {<br>this.carts = carts;<br>this.catalog = catalog;<br>this.discounts = discounts;<br>);
This simple factory creates a class and attaches its dependency information at the same time.
No decorators.
No reflection.
No parsing constructor signatures.
The class itself becomes the source of truth.
Concrete services can even use the class object itself as the dependency token, while abstractions can be represented by Symbol values.
Creating a Component
Let’s begin with the component() helper.
Its job is simple.
It creates a class, validates its configuration, and stores dependency metadata directly on the constructor using a private Symbol.
javascript
CopyCopied!<br>const COMPONENT_METADATA = Symbol("di.component.metadata");
const LIFETIMES = new Set([<br>"singleton",<br>"scoped",<br>"transient",<br>]);
function component(<br>name,<br>inject = {},<br>lifetime = "transient",<br>},<br>classFactory<br>) {<br>if (!name) {<br>throw new TypeError("Component name is required");
if (!LIFETIMES.has(lifetime)) {<br>throw new TypeError(`Unsupported lifetime: ${lifetime}`);
const ComponentClass = classFactory();
if (typeof ComponentClass !== "function") {<br>throw new TypeError("classFactory must return a class");
Object.defineProperties(ComponentClass, {<br>name: {<br>value: name,<br>configurable: true,<br>},<br>[COMPONENT_METADATA]: {<br>value: Object.freeze({<br>inject: Object.freeze({ ...inject }),<br>lifetime,<br>}),<br>enumerable: false,<br>writable: false,<br>configurable: false,<br>},<br>});
return ComponentClass;
Instead of maintaining a global metadata registry, every component carries its own configuration.
The metadata lives under a private Symbol, making accidental access almost impossible. It stays hidden during normal property enumeration while remaining available to the container whenever registration happens.
Giving each generated class...