Sitemap

How Angular Injector Works: Creating a Custom DI Container

8 min readOct 31, 2025

--

Press enter or click to view image in full size

In this article, we’ll explore how the Angular Injector works by building our own custom implementation that supports all the core provider strategies: useClass, useValue, useFactory, useExisting, and multi: true.

This article doesn’t cover the basic concepts of Angular’s Dependency Injection system — it assumes you’re already familiar with them. The focus here is on the internal mechanics and implementation of the injector.

To avoid cluttering the article with repeated type declarations, below you’ll find all the essential interfaces and types that we’ll be using throughout the code. Please take a moment to review them.

type Constructor = new (...args: any[]) => object; // A type that describes a class

type ProviderToken = // A type that represents a provider token
| Constructor
| InjectionToken<unknown>;

type ProviderConfig = // All valid provider configurations
| Constructor
| { provide: ProviderToken; useClass: Constructor; multi?: boolean }
| { provide: ProviderToken; useValue: any; multi?: boolean }
| { provide: ProviderToken; useExisting: ProviderToken; multi?: boolean }
| {
provide: ProviderToken;
useFactory: (...args: any[]) => any;
deps?: ProviderToken[];
multi?: boolean;
};

Creation

In Angular, an Injector is created using the static create method.

interface CreateInjectorConfig {
providers: ProviderConfig[];
parent?: Injector;
name?: string;
}

class Injector {
private parent?: Injector;
private name?: string;

static create({ providers, parent, name }: CreateInjectorConfig): Injector {
const injector = new Injector();

injector.parent = parent;
injector.name = name;

for (const providerConfig of providers) {
injector.provide(providerConfig);
}

return injector;
}

private provide(config: ProviderConfig): void {
// The implementation will be discussed further...
}
}

A new instance of the Injector is created, with its name and parent assigned if they were provided in the configuration. After that, all providers from the providers array are registered inside the injector. Once initialization is complete, the method returns a fully configured and ready-to-use instance of the Injector.

When designing the Injector, we intentionally restrict its creation via the new operator and instead provide a factory method called create. But why?

An injector created using create is immutable — once it's created, no new providers can be added. To enforce this, the provide method is made private. This way, no one from the outside can directly register providers in an already created injector. All providers must be defined in advance, at the time of calling create.

This approach makes the system predictable — the same token will always resolve the same way, safe — no one can replace an existing provider inside a working container, and isolated — each injector instance is independent and self-contained with its own dependencies.

Registration of providers

The provide method is used to register dependencies in the Injector.

class Injector {
private providers = new Map<ProviderToken, ProviderConfig | ProviderConfig[]>();

private provide(providerConfig: ProviderConfig): void {
const token = typeof providerConfig === 'function'
? providerConfig
: providerConfig.provide;

if (isSingleProvider(providerConfig)) {
this.providers.set(token, providerConfig);
} else {
const existingProviderConfig = this.providers.get(token);

if (Array.isArray(existingProviderConfig)) {
existingProviderConfig.push(providerConfig);
} else if (existingProviderConfig) {
this.providers.set(token, [existingProviderConfig, providerConfig]);
} else {
this.providers.set(token, [providerConfig]);
}
}
}
}

First, the token is determined: if a class is passed, the token is the class itself; if an object is passed, the token is taken from its provide field.

If the provider is a regular one, it is simply stored in the providers map, overwriting any previous entry. For multi-providers, multiple registrations are possible: if an array of providers already exists, the current one is added to it; if there was a single provider, it is converted into an array; and if there was nothing registered before, a new array is created with the current provider.

And I’d like to draw the reader’s attention to the fact that when dependencies are registered, no instances are created at that moment. The instances are only created when the dependency is actually requested by its token using the get method, which we’ll look at next.

Retrieving a Dependency

The get method accepts a dependency token and returns its resolved instance. However, the core resolution logic is implemented inside the internalGet method.

The internalGet method serves as a helper function that:

  1. Preserves the name of the originating injector (originName) to generate clear error messages during recursive resolution;
  2. Propagates the notFoundValue and optional flags up the injector chain, ensuring proper fallback behavior;
  3. Separates the external API from the internal logic, simplifying structure and promoting reuse;
  4. Controls the behavior of the self and skipSelf flags, ensuring they affect only the initial get call, as in Angular.
type ExtractOutputValue<T extends ProviderToken> = T extends Constructor
? InstanceType<T>
: T extends InjectionToken<infer U>
? U
: unknown;

interface InjectOptions {
optional?: boolean;
skipSelf?: boolean;
self?: boolean;
}

export class Injector {
private providers = new Map<ProviderToken, ProviderConfig | ProviderConfig[]>();
private resolvers = new Map<ProviderToken, unknown>();

private parent?: Injector;
private name?: string;

get<T extends ProviderToken, Output extends ExtractOutputValue<T>>(
token: T,
notFoundValue?: Output,
options?: InjectOptions,
): Output {
return this.internalGet(token, this.name, notFoundValue, options);
}

private internalGet<T extends ProviderToken, Output extends ExtractOutputValue<T>>(
token: T,
originName?: string,
notFoundValue?: Output,
options?: InjectOptions,
): Output {
const { optional, self, skipSelf } = options ?? {};

const shouldCheckSelf = !skipSelf || self;
const shouldCheckParent = !self;

if (shouldCheckSelf) {
const resolver = this.resolvers.get(token);

if (resolver) {
return resolver as Output;
}

const provider = this.providers.get(token);

if (provider) {
this.resolve(token, provider, originName);
return this.resolvers.get(token) as Output;
}
}

if (shouldCheckParent && this.parent) {
return this.parent.internalGet(token, originName, notFoundValue, { optional });
}

if (notFoundValue !== undefined) {
return notFoundValue;
}

if (optional) {
return null as Output;
}

throw new Error(`No provider for token: ${token}.`);
}

private resolve(
token: ProviderToken,
provider: ProviderConfig | ProviderConfig[],
originName?: string,
): void {
// The implementation will be discussed further...
}
}

Depending on the self and skipSelf flags, the injector determines whether to look for the token in the current injector and/or delegate the search to its parent. If the dependency has already been resolved earlier, it is retrieved from the resolvers cache. Otherwise, the injector checks for a registered provider in providers, resolves it, caches the result, and returns it.

Get Andrei Filimonchyk’s stories in your inbox

Join Medium for free to get updates from this writer.

If nothing is found locally and parent resolution is allowed, the request is delegated to the parent injector — but without passing the self and skipSelf flags to ensure they don’t affect nested dependencies. If the token still isn’t found but a notFoundValue is provided, that value is returned. If only optional: true is set, null is returned. And if none of these conditions are met, an error is thrown.

Priority of notFoundValue over optional: true

If the requested token is not found in the current or any parent injector, the behavior of the get method depends on the provided options. It’s important to note that notFoundValue takes precedence over the optional: true flag.

Dependency resolution

After a token is requested via the get method but hasn’t been resolved yet, the resolve method is executed.

type InjectableConstructor = Constructor & {
__injectable__?: true;
};

export class Injector {
private providers = new Map<ProviderToken, ProviderConfig | ProviderConfig[]>();
private resolvers = new Map<ProviderToken, unknown>();

private resolve(
token: ProviderToken,
provider: ProviderConfig | ProviderConfig[],
originName?: string,
): void {
if (Array.isArray(provider)) {
const resolvers = provider.map(config => this.getResolvedSingleProvider(config, originName));
this.resolvers.set(token, resolvers);
} else {
this.resolvers.set(token, this.getResolvedSingleProvider(provider, originName));
}
}

private getResolvedSingleProvider(
providerConfig: ProviderConfig,
originName?: string
): unknown {
if (typeof providerConfig === 'function') {
return this.createClassInstance(providerConfig, originName);
} else if ('useClass' in providerConfig) {
return this.createClassInstance(providerConfig.useClass, originName);
} else if ('useValue' in providerConfig) {
return providerConfig.useValue;
} else if ('useFactory' in providerConfig) {
const depsList = providerConfig.deps ?? [];
const resolvedDeps = depsList.map(token => this.internalGet(token, originName));

return providerConfig.useFactory(...resolvedDeps);
} else {
return this.internalGet(providerConfig.useExisting, originName);
}
}

private createClassInstance(
constructor: InjectableConstructor,
originName?: string
): object {
// The implementation will be discussed further...
}
}

The resolve method either creates a single instance or—if multi: true is used — generates an array of values by calling getResolvedSingleProvider for each provider. The result is then stored in the resolvers map for the corresponding token.

The getResolvedSingleProvider method is responsible for producing the actual value associated with a token and returning it. If the input is a class or an object with useClass, it calls createClassInstance, which creates an instance of the class. If useValue is used, the provided value is returned as-is. In the case of useFactory, it first recursively resolves all dependencies from the deps array, then invokes the factory function. For useExisting, the value is retrieved using the alternative token provided.

Creating Class Instances

type InjectableConstructor = Constructor & {
__injectable__?: true;
};

function Injectable(): Function {
return (constructor: Constructor): Constructor & { __injectable__: true } => {
constructor.__injectable__ = true;
return constructor;
};
}

export class Injector {
private providers = new Map<ProviderToken, ProviderConfig | ProviderConfig[]>();
private resolvers = new Map<ProviderToken, unknown>();


private createClassInstance<T extends InjectableConstructor, Instance extends InstanceType<T>>(
constructor: T,
originName?: string,
): Instance {
if (!constructor.__injectable__) {
throw new Error(`Cannot instantiate class ${constructor.name} because it does not have the @Injectable decorator`);
}

const depsList: Constructor[] = Reflect.getMetadata('design:paramtypes', constructor) ?? [];
const resolvedDeps = depsList.map(dependency => this.internalGet(dependency, originName));

return new constructor(...resolvedDeps) as Instance;
}
}

To correctly create a class instance, it’s first necessary to determine its dependencies — the constructor parameters. This is achieved using the Reflect.getMetadata method from the reflect-metadata library. The method takes two arguments: the metadata key (in our case, ‘design:paramtypes’) and the class from which the dependencies should be extracted.

You must import reflect-metadata once in your entry point .ts file (such as main.ts or index.ts) before using any decorators or dependency resolution features.

However, it’s important to understand that TypeScript does not generate metadata for classes by default. For this metadata to be available, the class must be explicitly marked with a decorator. This is exactly why the custom Injectable decorator was introduced. It not only indicates that the class can be used as a provider, but also enables the generation of metadata required for Reflect.getMetadata to work properly. This metadata contains the list of constructor parameters. Without such a decorator, the array of dependencies will be empty — making it impossible to instantiate a class that has constructor parameters.

Additionally, for TypeScript decorators to generate metadata, you need to enable two important compiler options in your tsconfig.json file.

{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}

After all necessary conditions for TypeScript are met and reflect-metadata is properly imported, the constructor parameters can be correctly retrieved using Reflect.getMetadata.

Example

import { Injector } from './injector';
import { Injectable } from './injector.decorator';

@Injectable()
export class Engine {
type = 'diesel';
cylinders = 8;

start(): void {
console.log('Engine has been started.');
}
}

@Injectable()
class Car {
constructor(public engine: Engine) {}
}

const injector = Injector.create({ providers: [Car, Engine] });
const carInstance = injector.get(Car);

console.log(carInstance); // Car class instance

In this example, we register the Car and Engine classes in the Injector, marking them with the Injectable decorator beforehand. After registration, we simply request an instance of Car from the injector — and receive a fully initialized object with all its dependencies injected.

The image below clearly illustrates the list of constructor parameters of the Car class, as retrieved using Reflect.getMetadata.

Press enter or click to view image in full size

The next step is to resolve all the dependencies obtained from the constructor — in our case, a single instance of the Engine class.

Press enter or click to view image in full size

Next, we create an instance of the Car class by passing all the required dependencies into its constructor.

Project code

If you’re interested in the project and would like to explore it further, you can easily do so by visiting the GitHub repository or installing the npm package.

GitHub: https://github.com/emmat-york/angular-style-injector

NPM: https://www.npmjs.com/package/angular-style-injector

--

--