@mmstack/di
A collection of dependency injection utilities for Angular that simplify working with InjectionTokens and provide type-safe patterns for creating injectable services.

Installation
npm install @mmstack/di
Utilities
This library provides the following utilities:
injectable - Creates a typed InjectionToken with inject and provide helper functions for type-safe dependency injection.
injectLazy - Defers the resolution and instantiation of a token until it is actually accessed.
createRunInInjectionContext - Captures an injection context securely and returns a runner function, useful for inject() inside async callbacks.
rootInjectable - Creates a lazily-initialized root-level injectable that maintains a singleton instance.
createScope - Creates a dependency injection scope that caches singletons based on the factory function.
injectable
Creates a typed InjectionToken with convenient, type-safe inject and provider functions, eliminating boilerplate and ensuring type safety throughout your dependency injection flow. It returns a tuple of [injectFn, provideFn] that work together seamlessly.
The injectable function supports three patterns:
- Basic - Returns
T | null when not provided
- With Fallback - Returns a default value when not provided, can be either
- With Lazy Fallback - Same as with fallback, but the fallback iz lazily evaluated, useful for expensive fallbacks or ones that require injection
- With Error - Throws a custom error message when not provided
Basic Usage
import { Component, Injectable } from '@angular/core';
import { injectable } from '@mmstack/di';
const [injectLogger, provideLogger] = injectable<Logger>('Logger');
@Component({
selector: 'app-root',
providers: [
provideLogger({
log: (msg) => console.log(`[LOG]: ${msg}`),
error: (msg) => console.error(`[ERROR]: ${msg}`),
}),
],
})
export class AppComponent {}
@Injectable()
export class DataService {
private logger = injectLogger();
fetchData() {
this.logger?.log('Fetching data...');
}
}
With Factory Dependencies
import { HttpClient } from '@angular/common/http';
import { injectable } from '@mmstack/di';
interface ApiConfig {
baseUrl: string;
timeout: number;
}
const [injectApiConfig, provideApiConfig] = injectable<ApiConfig>('ApiConfig');
@Component({
providers: [
provideApiConfig(
(http: HttpClient) => ({
baseUrl: 'https://api.example.com',
timeout: 5000,
}),
[HttpClient],
),
],
})
export class AppComponent {}
With Fallback Value
When you want to provide a default value instead of returning null:
import { injectable } from '@mmstack/di';
interface Theme {
primary: string;
secondary: string;
}
const [injectTheme, provideTheme] = injectable<Theme>('Theme', {
fallback: {
primary: '#007bff',
secondary: '#6c757d',
},
});
const [injectTheme, provideTheme] = injectable<Theme>('Theme', {
lazyFallback: () => {
return {
primary: inject(APP_PRIMARY),
secondary: '#6c757d',
},
}
});
@Injectable()
export class ThemeService {
private theme = injectTheme();
getPrimaryColor() {
return this.theme.primary;
}
}
With Error Message
When you want to enforce that the value must be provided:
import { injectable } from '@mmstack/di';
const [injectApiKey, provideApiKey] = injectable<string>('ApiKey', {
errorMessage: 'API Key is required! Please provide it using provideApiKey().',
});
@Injectable()
export class ApiService {
private apiKey = injectApiKey();
makeRequest() {
return fetch(`https://api.example.com?key=${this.apiKey}`);
}
}
Providing Functions as Values
The provideFn correctly handles functions as values (not factories):
import { injectable } from '@mmstack/di';
type Validator = (value: string) => boolean;
const [injectValidator, provideValidator] = injectable<Validator>('Validator');
@Component({
providers: [
provideValidator((value: string) => value.length > 5),
],
})
export class FormComponent {
private validator = injectValidator();
validate(input: string) {
return this.validator?.(input) ?? false;
}
}
Advanced: Scoped Context Pattern
Create context-like dependency injection patterns:
import { Component, Injectable } from '@angular/core';
import { injectable } from '@mmstack/di';
interface FormContext {
formId: string;
isDirty: boolean;
submit: () => void;
}
const [injectFormContext, provideFormContext] = injectable<FormContext>('FormContext', {
errorMessage: 'FormContext must be provided by a parent form component',
});
@Component({
selector: 'app-form',
providers: [
provideFormContext({
formId: 'user-form',
isDirty: false,
submit: () => console.log('Submitting form...'),
}),
],
template: `
<form>
<app-form-field></app-form-field>
<app-form-actions></app-form-actions>
</form>
`,
})
export class FormComponent {}
@Component({
selector: 'app-form-field',
template: `<input [id]="formContext.formId + '-input'" />`,
})
export class FormFieldComponent {
formContext = injectFormContext();
}
@Component({
selector: 'app-form-actions',
template: `<button (click)="formContext.submit()">Submit</button>`,
})
export class FormActionsComponent {
formContext = injectFormContext();
}
injectLazy
Defers the resolution and instantiation of an injection token until the returned getter function is actually called.
Angular's native inject() resolves and instantiates dependencies immediately during the construction phase. If a service is heavily resource-intensive but only needed conditionally (like an export service or a complex editor), injectLazy allows you to capture the injection context immediately while delaying instantiation. The resolved value is cached, acting as a standard scoped singleton on subsequent calls.
Basic Usage
import { Component, HostListener } from '@angular/core';
import { injectLazy } from '@mmstack/di';
@Component({
selector: 'app-export-button',
template: `<button>Export Data</button>`,
})
export class ExportButtonComponent {
private getExportService = injectLazy(HeavyExportService);
@HostListener('click')
export() {
const service = this.getExportService();
service.doExport();
}
}
With Options
It fully supports Angular's InjectOptions and guarantees correct return types (e.g., returning T | null when optional: true):
const getOptionalDep = injectLazy(MyToken, { optional: true });
const dep = getOptionalDep();
createRunInInjectionContext
Captures an injection context securely and returns a runner function.
This utility allows you to execute callbacks inside the captured context at a later time. It solves the common pain point of needing to use inject() inside asynchronous callbacks, RxJS streams, or external event listeners where the framework's implicit injection context has been lost.
Basic Usage
import { Component, OnInit, inject } from '@angular/core';
import { createRunInInjectionContext } from '@mmstack/di';
@Component({
selector: 'app-dialog-trigger',
template: `<button>Open Dialog</button>`,
})
export class DialogTriggerComponent implements OnInit {
private runInContext = createRunInInjectionContext();
ngOnInit() {
someExternalLibrary.on('openEvent', () => {
this.runInContext(() => {
const dialog = inject(DialogService);
dialog.open();
});
});
}
}
With Explicit Injector
You can also completely bypass the ambient capture and provide an Injector explicitly:
const runner = createRunInInjectionContext(appRef.injector);
runner(() => {
const router = inject(Router);
router.navigate(['/home']);
});
rootInjectable
Creates a lazily-initialized root-level injectable that maintains a singleton instance across your entire application. The factory function runs in the root injection context on first access, allowing you to inject other dependencies.
Important: This should only be used for pure singletons. If you need scoped instances, use regular @Injectable services with providedIn or component-level providers.
Basic Usage
import { Injectable } from '@angular/core';
import { rootInjectable } from '@mmstack/di';
interface Logger {
log: (message: string) => void;
}
const injectLogger = rootInjectable<Logger>(() => ({
log: (message) => console.log(`[${new Date().toISOString()}] ${message}`),
}));
@Injectable()
export class DataService {
private logger = injectLogger();
fetchData() {
this.logger.log('Fetching data...');
}
}
@Injectable()
export class UserService {
private logger = injectLogger();
saveUser() {
this.logger.log('Saving user...');
}
}
With Dependencies
The factory function receives the root injector, allowing you to inject other services:
import { HttpClient } from '@angular/common/http';
import { rootInjectable } from '@mmstack/di';
interface ApiClient {
get: (url: string) => Promise<any>;
post: (url: string, data: any) => Promise<any>;
}
const injectApiClient = rootInjectable<ApiClient>((injector) => {
const http = injector.get(HttpClient);
return {
get: (url) => fetch(url).then((r) => r.json()),
post: (url, data) =>
fetch(url, {
method: 'POST',
body: JSON.stringify(data),
}).then((r) => r.json()),
};
});
@Injectable()
export class ProductService {
private api = injectApiClient();
loadProducts() {
return this.api.get('/api/products');
}
}
State Management Example
Create a simple global state manager:
import { signal, computed } from '@angular/core';
import { rootInjectable } from '@mmstack/di';
interface User {
id: string;
name: string;
email: string;
}
interface AuthState {
user: User | null;
isAuthenticated: boolean;
}
const injectAuthStore = rootInjectable(() => {
const user = signal<User | null>(null);
const isAuthenticated = computed(() => user() !== null);
return {
user: user.asReadonly(),
isAuthenticated,
login: (userData: User) => user.set(userData),
logout: () => user.set(null),
};
});
@Injectable()
export class AuthService {
private store = injectAuthStore();
login(email: string, password: string) {
this.store.login({
id: '123',
name: 'John Doe',
email,
});
}
logout() {
this.store.logout();
}
}
@Component({
selector: 'app-navbar',
template: `
@if (authStore.isAuthenticated()) {
<span>{{ authStore.user()?.name }}</span>
<button (click)="logout()">Logout</button>
}
`,
})
export class NavbarComponent {
authStore = injectAuthStore();
logout() {
this.authStore.logout();
}
}
createScope
Creates a dependency injection scope using a dynamic InjectionToken representing a caching registry. Factories executed within the scope run in the Angular injection context and their results are cached, effectively creating scoped singletons, that are destroyed when the scoped provider is. It returns a tuple of [injectable, provider].
Basic Usage
import { Component, inject } from '@angular/core';
import { createScope } from '@mmstack/di';
const [injectableFeatureItem, provideFeatureScope] = createScope('FeatureScope');
@Component({
selector: 'app-feature',
providers: [provideFeatureScope()],
template: `<app-child></app-child>`,
})
export class FeatureComponent {}
const useFeatureItem = injectableFeatureItem(() => {
const someDep = inject(SomeDependency);
return {
id: Math.random(),
doWork: () => someDep.work(),
};
});
@Component({
selector: 'app-child',
template: `<div>Child Item ID: {{ item.id }}</div>`,
})
export class ChildComponent {
item = useFeatureItem();
}
License
MIT © Miha Mulec