Maîtrisez la Dependency Injection Angular : InjectionToken<T>, inject(), providers avancés et type-safety pour apps enterprise robustes.
Les fondamentaux de la DI Angular
La Dependency Injection (DI) est l'un des piliers fondamentaux d'Angular. Contrairement à d'autres frameworks où la DI est optionnelle ou superficielle, Angular a construit tout son écosystème autour de ce design pattern. Comprendre la DI, c'est comprendre Angular dans sa profondeur.
Le principe est simple : plutôt que de créer ses dépendances manuellement via new MonService(), une classe déclare ce dont elle a besoin, et l'injecteur Angular se charge de les créer et de les fournir. Ce mécanisme produit un couplage faible entre les composants et leurs dépendances — clé de l'architecture scalable.
Comment fonctionne l'injecteur Angular
L'injecteur Angular maintient un registre des providers — des recettes qui lui indiquent comment créer chaque dépendance. Lorsqu'un composant, une directive ou un service demande une dépendance, l'injecteur :
- Recherche le provider correspondant dans l'injecteur courant
- Remonte la hiérarchie si le token n'est pas trouvé
- Instancie la dépendance (ou retourne une instance existante si singleton)
- Injecte la valeur dans la classe demandeuse
// Service déclaré avec providedIn: 'root' — singleton global
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root', // Enregistré au niveau racine de l'application
})
export class UserService {
private users: string[] = [];
// Méthode exposée à l'ensemble de l'application
getUsers(): string[] {
return this.users;
}
addUser(name: string): void {
// Ajout dans le tableau interne du singleton
this.users.push(name);
}
}
Pourquoi la type-safety change tout
Sans type-safety, la DI peut devenir une boîte noire : on injecte un token et on espère récupérer le bon type. Avec TypeScript et les outils Angular modernes, chaque injection est vérifiée à la compilation. TypeScript détecte immédiatement si vous utilisez une méthode inexistante sur une dépendance injectée.
any de vos providers — vous perdez l'intégralité de la valeur ajoutée de TypeScript.
// ❌ Anti-pattern — perte totale de type-safety
const service = inject(UserService) as any; // Danger : plus aucune vérification
// ✅ Correct — type inféré automatiquement par Angular
const service = inject(UserService); // TypeScript sait que c'est UserService
service.getUsers(); // Autocomplétion + vérification à la compilation
// service.mauvaisNom(); // ❌ Erreur TypeScript détectée immédiatement
Dans les projets enterprise, cette distinction est critique. Une application Angular avec strictNullChecks activé et zéro any dans ses providers offre une garantie que les refactorings massifs ne casseront pas silencieusement les dépendances.
Le cycle de vie d'un service injectable
// Exemple complet — service avec état et lifecycle
import { Injectable, OnDestroy } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class NotificationService implements OnDestroy {
// État interne — type explicite Signal-ready
private listeners: Map<string, Set<(msg: string) => void>> = new Map();
on(event: string, callback: (msg: string) => void): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(callback);
}
emit(event: string, message: string): void {
// Notifie tous les abonnés de l'événement
this.listeners.get(event)?.forEach(cb => cb(message));
}
ngOnDestroy(): void {
// Nettoyage complet à la destruction — évite les fuites mémoire
this.listeners.clear();
}
}
InjectionToken<T> : tokens type-safe
Lorsque vous souhaitez injecter une valeur qui n'est pas une classe (une chaîne de configuration, un objet d'environnement, un boolean de feature flag), vous ne pouvez pas utiliser le nom de la classe comme token. C'est là qu'InjectionToken<T> entre en jeu — un mécanisme type-safe pour injecter n'importe quelle valeur.
Créer un InjectionToken typé
// tokens.ts — centraliser tous les tokens dans un fichier dédié
import { InjectionToken } from '@angular/core';
// Interface de configuration API — définit le contrat de type
export interface ApiConfig {
baseUrl: string;
timeout: number;
retryAttempts: number;
}
// Token typé : Angular garantit que la valeur injectée est ApiConfig
export const API_CONFIG = new InjectionToken<ApiConfig>('API_CONFIG');
// Token pour une feature flag booléenne
export const ENABLE_DARK_MODE = new InjectionToken<boolean>('ENABLE_DARK_MODE');
// Token pour un tableau de plugins — type tableau générique
export const APP_PLUGINS = new InjectionToken<Plugin[]>('APP_PLUGINS');
// Token pour une fonction de formatage — type fonctionnel
export const DATE_FORMATTER = new InjectionToken<(date: Date) => string>('DATE_FORMATTER');
Fournir le token dans un provider
// app.config.ts (Angular standalone)
import { ApplicationConfig } from '@angular/core';
import { API_CONFIG, ENABLE_DARK_MODE, DATE_FORMATTER } from './tokens';
export const appConfig: ApplicationConfig = {
providers: [
{
provide: API_CONFIG,
// 'satisfies' vérifie le type sans l'élargir — plus sûr que 'as ApiConfig'
useValue: {
baseUrl: 'https://api.monapp.com',
timeout: 5000,
retryAttempts: 3,
} satisfies ApiConfig,
},
{
provide: ENABLE_DARK_MODE,
// useFactory — évalué au moment de l'injection, pas à la compilation
useFactory: () => window.matchMedia('(prefers-color-scheme: dark)').matches,
},
{
provide: DATE_FORMATTER,
// Injecter une fonction — type-safe, testable, remplaçable
useValue: (date: Date) => new Intl.DateTimeFormat('fr-FR').format(date),
},
],
};
Injecter un token type-safe dans un composant
// header.component.ts
import { Component, inject } from '@angular/core';
import { API_CONFIG, ENABLE_DARK_MODE, DATE_FORMATTER } from '../tokens';
@Component({
selector: 'app-header',
template: `
<nav>
<span>API : {{ config.baseUrl }}</span>
<span *ngIf="darkMode">Mode sombre actif</span>
<time>{{ formatDate(today) }}</time>
</nav>
`,
})
export class HeaderComponent {
// TypeScript infère ApiConfig — pas de cast, pas de any
readonly config = inject(API_CONFIG);
// TypeScript infère boolean — impossible d'appeler .toUpperCase() par erreur
readonly darkMode = inject(ENABLE_DARK_MODE);
// TypeScript infère (date: Date) => string — autocomplétion complète
readonly formatDate = inject(DATE_FORMATTER);
readonly today = new Date();
}
'API_CONFIG') apparaît dans les messages d'erreur Angular — choisissez des noms descriptifs et uniques dans votre application.
InjectionToken avec factory intégrée et valeur par défaut
// Token auto-suffisant — pas besoin de provider séparé dans app.config.ts
export const DEFAULT_LOCALE = new InjectionToken<string>('DEFAULT_LOCALE', {
providedIn: 'root', // Portée globale automatique
factory: () => navigator.language || 'fr-FR', // Valeur par défaut dynamique
});
// Token pour un service de logging avec implémentation par défaut
export interface LogDriver {
debug(msg: string): void;
warn(msg: string): void;
error(msg: string, err?: Error): void;
}
export const LOG_DRIVER = new InjectionToken<LogDriver>('LOG_DRIVER', {
providedIn: 'root',
factory: () => ({
debug: (msg) => console.debug(msg),
warn: (msg) => console.warn(msg),
error: (msg, err) => console.error(msg, err),
}),
});
inject() vs constructeur : comparaison
Angular offre deux façons d'injecter des dépendances : la méthode classique via le constructeur et la méthode moderne via la fonction inject(). Les deux sont type-safe, mais leurs cas d'usage divergent profondément.
L'injection par constructeur — méthode classique
// Méthode traditionnelle — fonctionne dans toutes les versions Angular
import { Component, OnInit } from '@angular/core';
import { UserService } from '../services/user.service';
import { LogService } from '../services/log.service';
import { AnalyticsService } from '../services/analytics.service';
@Component({
selector: 'app-profile',
templateUrl: './profile.component.html',
})
export class ProfileComponent implements OnInit {
// TypeScript vérifie les types via les paramètres annotés
constructor(
private readonly userService: UserService, // Singleton global
private readonly logService: LogService, // Singleton global
private readonly analytics: AnalyticsService, // Singleton global
) {}
ngOnInit(): void {
// Autocomplétion complète — TypeScript connaît tous les types
const users = this.userService.getUsers();
this.logService.info('ProfileComponent initialisé');
this.analytics.track('page_view', { page: 'profile' });
}
}
L'injection avec inject() — méthode moderne Angular 14+
// Méthode recommandée pour les nouveaux projets Angular 14+
import { Component, inject, OnInit } from '@angular/core';
import { UserService } from '../services/user.service';
import { LogService } from '../services/log.service';
import { AnalyticsService } from '../services/analytics.service';
@Component({
selector: 'app-profile',
templateUrl: './profile.component.html',
})
export class ProfileComponent implements OnInit {
// Propriétés de classe — lisibles, localisées, pas d'ordre de constructeur à gérer
private readonly userService = inject(UserService);
private readonly logService = inject(LogService);
private readonly analytics = inject(AnalyticsService);
ngOnInit(): void {
// Même type-safety que le constructeur — aucune régression
const users = this.userService.getUsers();
this.logService.info('ProfileComponent initialisé');
this.analytics.track('page_view', { page: 'profile' });
}
}
Le vrai avantage : les composables réutilisables
Le pouvoir de inject() réside dans la possibilité de créer des fonctions composables — des fonctions utilitaires qui encapsulent de la logique et des injections, réutilisables dans plusieurs composants sans héritage ni mixin :
// use-auth.ts — composable réutilisable dans n'importe quel composant
import { inject, signal, computed } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from './auth.service';
import { User } from './models/user.model';
// Appelable depuis n'importe quel composant — inject() fonctionne hors constructeur
export function useAuth() {
const authService = inject(AuthService);
const router = inject(Router);
// État réactif local via Signals — déclaratif et type-safe
const isLoading = signal(false);
const currentUser = signal<User | null>(authService.getUser());
// Computed : dérivé automatiquement de currentUser
const isAuthenticated = computed(() => currentUser() !== null);
const userRole = computed(() => currentUser()?.role ?? 'guest');
async function login(email: string, password: string): Promise<void> {
isLoading.set(true); // Démarre l'indicateur de chargement
try {
const user = await authService.login(email, password);
currentUser.set(user); // Met à jour le Signal
router.navigate(['/dashboard']); // Redirige après connexion
} catch (err) {
console.error('Échec connexion :', err);
throw err; // Propage pour gestion dans le composant
} finally {
isLoading.set(false); // Arrête le spinner (succès ou erreur)
}
}
function logout(): void {
authService.logout();
currentUser.set(null); // Signal mis à null — computed se recalcule
router.navigate(['/login']);
}
// Retour type-safe — TypeScript infère chaque type automatiquement
return { isLoading, currentUser, isAuthenticated, userRole, login, logout };
}
// login.component.ts — utilise le composable, zéro duplication
import { Component } from '@angular/core';
import { useAuth } from '../composables/use-auth';
@Component({
selector: 'app-login',
template: `
<form (ngSubmit)="auth.login(email, password)">
<input [(ngModel)]="email" type="email" placeholder="Email" />
<input [(ngModel)]="password" type="password" placeholder="Mot de passe" />
<button type="submit" [disabled]="auth.isLoading()">
{{ auth.isLoading() ? 'Connexion...' : 'Se connecter' }}
</button>
</form>
<p *ngIf="auth.isAuthenticated()">
Bienvenue {{ auth.currentUser()?.name }} ({{ auth.userRole() }})
</p>
`,
})
export class LoginComponent {
// Toute la logique auth encapsulée — réutilisable dans RegisterComponent aussi
protected readonly auth = useAuth();
protected email = '';
protected password = '';
}
| Critère | Constructeur | inject() |
|---|---|---|
| Type-safety | ✅ Oui | ✅ Oui |
| Composables / fonctions utilitaires | ❌ Impossible | ✅ Natif |
| Guards fonctionnels (Angular 15+) | ❌ Impossible | ✅ Supporté |
| Interceptors fonctionnels | ❌ Impossible | ✅ Supporté |
| Lisibilité (longue liste de deps) | ⚠️ Constructeur long | ✅ Propriétés alignées |
| Compatibilité Angular | ✅ Toutes versions | Angular 14+ |
| Recommandation 2026 | Maintien de l'existant | ✅ Nouveaux projets |
Les 4 providers avancés
Angular propose quatre types de providers, chacun adapté à un cas d'usage précis. Maîtriser leurs différences permet de concevoir une architecture DI robuste, flexible, et entièrement type-safe.
1. useClass — Substituer une implémentation
// Idéal pour le pattern Strategy ou les environnements (dev/prod/test)
import { InjectionToken, Injectable } from '@angular/core';
// Interface — contrat type-safe pour les implémentations
export interface Logger {
log(message: string): void;
error(message: string, error?: Error): void;
}
// Implémentation production — envoie les erreurs à un service de monitoring
@Injectable()
export class ProductionLogger implements Logger {
log(message: string): void {
// En production : envoyer à un service de monitoring (ex: Datadog)
console.log(`[PROD] ${message}`);
}
error(message: string, error?: Error): void {
console.error(`[PROD] ${message}`, error);
// captureException(error); // Sentry / Datadog en production réelle
}
}
// Implémentation développement — logs colorés, verbeux
@Injectable()
export class DevLogger implements Logger {
log(message: string): void {
console.log(`%c[DEV] ${message}`, 'color: #2196F3'); // Bleu en dev
}
error(message: string, error?: Error): void {
console.error(`%c[DEV ERROR] ${message}`, 'color: red', error);
}
}
// Token pour l'interface — injecter le contrat, pas l'implémentation
export const LOGGER = new InjectionToken<Logger>('LOGGER');
// Basculer automatiquement selon l'environnement
export const loggerProvider = {
provide: LOGGER,
useClass: environment.production ? ProductionLogger : DevLogger,
};
2. useValue — Injecter des constantes et objets
// Parfait pour la configuration, les constantes d'environnement
export interface DatabaseConfig {
host: string;
port: number;
database: string;
poolSize: number;
}
export const DB_CONFIG = new InjectionToken<DatabaseConfig>('DB_CONFIG');
export const dbConfigProvider = {
provide: DB_CONFIG,
// 'satisfies' = vérification TypeScript sans élargissement de type
// Plus sûr que 'as DatabaseConfig' qui ne vérifie rien
useValue: {
host: 'localhost',
port: 5432,
database: 'monapp_dev',
poolSize: 10,
} satisfies DatabaseConfig,
};
3. useFactory — Créer dynamiquement selon le contexte
// La factory reçoit ses propres dépendances via 'deps' — type-safe
export const HTTP_CLIENT_CONFIG = new InjectionToken<HttpClientConfig>('HTTP_CLIENT_CONFIG');
export const httpConfigProvider = {
provide: HTTP_CLIENT_CONFIG,
useFactory: (authService: AuthService, config: AppConfig): HttpClientConfig => {
// Construction dynamique — dépend de l'état de l'application au démarrage
return {
baseUrl: config.apiUrl,
headers: {
'Authorization': `Bearer ${authService.getToken() ?? ''}`,
'X-App-Version': config.version,
'Content-Type': 'application/json',
},
timeout: config.httpTimeout,
};
},
// Dépendances injectées dans la factory — dans le même ordre que les paramètres
deps: [AuthService, APP_CONFIG],
};
4. useExisting — Alias type-safe entre tokens
// Cas d'usage : maintenir la rétrocompatibilité sans dupliquer les instances
// Typique dans les librairies Angular qui évoluent en gardant les anciens tokens
// Ancien token (déprécié mais conservé pour les consommateurs existants)
export const LEGACY_LOGGER = new InjectionToken<Logger>('LEGACY_LOGGER');
// Nouveau token préféré
export const LOGGER_V2 = new InjectionToken<Logger>('LOGGER_V2');
// Les deux tokens retournent la MÊME instance — zéro overhead mémoire
export const loggerAliasProvider = {
provide: LEGACY_LOGGER,
useExisting: LOGGER_V2, // Alias pur : aucune nouvelle instanciation
};
// Avantage : les consommateurs de LEGACY_LOGGER continuent de fonctionner
// sans modification, tout en bénéficiant de la nouvelle implémentation LOGGER_V2
Hiérarchie des injecteurs Angular
Angular maintient un arbre d'injecteurs qui reflète la structure de l'application. Comprendre cette hiérarchie est essentiel pour contrôler précisément le scope et le cycle de vie de chaque service.
Les niveaux de la hiérarchie
| Niveau | Syntaxe | Scope | Cas d'usage typique |
|---|---|---|---|
| Root | providedIn: 'root' |
Toute l'application | Auth, HTTP, Store, Notifications |
| Platform | providedIn: 'platform' |
Toutes les apps (micro-app) | Services partagés multi-applications |
| Environment | providedIn: 'environment' (v16+) |
Environnement d'exécution | Services SSR vs client-only |
| Route | providers: [...] dans la route |
Composants de la route | Services lazy-loaded, état temporaire |
| Component | providers: [...] dans @Component |
Composant + enfants directs | État local, formulaires, wizards |
Services à portée locale — Component-level DI
// Chaque instance du composant obtient sa PROPRE instance du service
// Parfait pour les formulaires complexes ou les états temporaires
@Component({
selector: 'app-cart',
templateUrl: './cart.component.html',
providers: [
CartService, // Nouvelle instance créée à chaque instanciation du composant
// Quand CartComponent est détruit → CartService est aussi détruit automatiquement
],
})
export class CartComponent {
// inject() récupère l'instance LOCALE (pas le singleton root)
private readonly cart = inject(CartService);
// À la destruction du composant : CartService.ngOnDestroy() est appelé
// Pas de fuite mémoire, pas d'état persistant entre sessions utilisateur
}
Services dans les routes lazy-loaded
// app.routes.ts — provider scoped à une route
export const appRoutes: Routes = [
{
path: 'checkout',
loadComponent: () => import('./checkout/checkout.component'),
providers: [
CheckoutService, // Singleton pour cette route uniquement
PaymentService, // Détruit quand l'utilisateur quitte /checkout
],
},
{
path: 'admin',
loadChildren: () => import('./admin/admin.routes'),
providers: [
AdminService, // Disponible dans tout le sous-arbre /admin
],
},
];
providedIn: 'root' et dans providers d'un composant, le composant obtiendra une instance différente du service root. Les deux coexistent en mémoire — risk d'incohérence d'état silencieuse.
Modificateurs d'injection : @Optional, @Self, @SkipSelf
// Contrôle fin de la résolution des dépendances dans la hiérarchie
import { Component, inject } from '@angular/core';
@Component({ /* ... */ })
export class AdvancedComponent {
// optional: true — retourne null si le service n'est pas fourni (pas d'erreur)
// Type inféré : OptionalLogger | null — TypeScript oblige à gérer le null
private readonly optLogger = inject(OptionalLogger, { optional: true });
// self: true — cherche UNIQUEMENT dans l'injecteur de ce composant (pas les parents)
private readonly localService = inject(LocalService, { self: true });
// skipSelf: true — ignore l'injecteur courant, cherche dans les parents
// Utile pour accéder à l'instance parent d'un service partagé
private readonly parentState = inject(ParentStateService, { skipSelf: true });
logIfAvailable(message: string): void {
// TypeScript force la vérification null grâce à optional: true
this.optLogger?.log(message); // Opérateur ?. requis car type est Logger | null
}
}
Patterns avancés : composables et generics
Les développeurs experts combinent inject(), les génériques TypeScript et les Signals pour créer des abstractions puissantes — réutilisables, type-safe, et maintenables à grande échelle.
Pattern : service CRUD générique
// generic-crud.service.ts — éliminer la répétition dans les services CRUD
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
// Contrainte de type : toute entité doit avoir un id
export interface Entity {
id: string | number;
}
// T est contraint à être une Entity — TypeScript applique cette règle à la compilation
@Injectable()
export class GenericCrudService<T extends Entity> {
protected readonly http = inject(HttpClient);
constructor(protected readonly endpoint: string) {}
// Toutes les méthodes retournent des types génériques précis
getAll(): Observable<T[]> {
return this.http.get<T[]>(this.endpoint);
}
getById(id: T['id']): Observable<T> {
// T['id'] = le type exact de id (string OU number, selon T)
return this.http.get<T>(`${this.endpoint}/${id}`);
}
create(entity: Omit<T, 'id'>): Observable<T> {
// Omit<T, 'id'> : T sans le champ id — l'API génère l'id
return this.http.post<T>(this.endpoint, entity);
}
update(id: T['id'], changes: Partial<Omit<T, 'id'>>): Observable<T> {
// Partial rend tous les champs optionnels — mise à jour partielle PATCH
return this.http.patch<T>(`${this.endpoint}/${id}`, changes);
}
delete(id: T['id']): Observable<void> {
return this.http.delete<void>(`${this.endpoint}/${id}`);
}
}
// user.service.ts — spécialisation du service générique avec 0 duplication
import { Injectable } from '@angular/core';
import { GenericCrudService, Entity } from './generic-crud.service';
import { Observable } from 'rxjs';
export interface User extends Entity {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'guest'; // Union discriminante — type sûr
createdAt: Date;
}
@Injectable({ providedIn: 'root' })
export class UserService extends GenericCrudService<User> {
constructor() {
super('/api/users'); // Endpoint spécifique à User
}
// Méthode métier spécifique — getById, create, update, delete sont hérités
getByRole(role: User['role']): Observable<User[]> {
// User['role'] = 'admin' | 'user' | 'guest' — TypeScript empêche 'superadmin'
return this.http.get<User[]>(`${this.endpoint}?role=${role}`);
}
}
Pattern : composable de pagination générique
// use-pagination.ts — composable réutilisable avec type générique T
import { inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
// Contrat de réponse paginée — tout backend doit respecter cette structure
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
}
// T est le type des éléments — inféré à l'appel : usePagination<User>(...)
export function usePagination<T>(endpoint: string, defaultPageSize = 10) {
const http = inject(HttpClient);
// État réactif type-safe via Signals
const currentPage = signal(1);
const pageSize = signal(defaultPageSize);
const items = signal<T[]>([]); // T[] garanti par le générique
const total = signal(0);
const isLoading = signal(false);
const errorMessage = signal<string | null>(null);
// Computeds : dérivés automatiquement, pas de state manuels
const totalPages = computed(() => Math.ceil(total() / pageSize()));
const hasNext = computed(() => currentPage() < totalPages());
const hasPrev = computed(() => currentPage() > 1);
async function loadPage(page: number): Promise<void> {
isLoading.set(true);
errorMessage.set(null); // Réinitialise l'erreur précédente
try {
const response = await firstValueFrom(
http.get<PaginatedResponse<T>>(
`${endpoint}?page=${page}&size=${pageSize()}`
)
);
items.set(response.data); // T[] — type garanti par PaginatedResponse<T>
total.set(response.total); // number garanti
currentPage.set(page);
} catch (err) {
// Capture d'erreur type-safe
errorMessage.set(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
isLoading.set(false);
}
}
return {
items, total, currentPage, pageSize, isLoading, errorMessage,
totalPages, hasNext, hasPrev,
loadPage,
nextPage: () => hasNext() ? loadPage(currentPage() + 1) : Promise.resolve(),
prevPage: () => hasPrev() ? loadPage(currentPage() - 1) : Promise.resolve(),
refresh: () => loadPage(currentPage()),
};
}
// users-list.component.ts — utilisation du composable avec User
import { Component, OnInit } from '@angular/core';
import { usePagination } from '../composables/use-pagination';
import { User } from '../models/user.model';
@Component({
selector: 'app-users-list',
template: `
<div *ngIf="pagination.isLoading()" class="spinner-border text-primary"></div>
<div *ngIf="pagination.errorMessage()" class="alert alert-danger">
{{ pagination.errorMessage() }}
</div>
<ul class="list-group">
<!-- items() est Signal<User[]> — accès type-safe à user.name, user.role -->
<li class="list-group-item" *ngFor="let user of pagination.items()">
{{ user.name }} <span class="badge bg-primary">{{ user.role }}</span>
</li>
</ul>
<nav>
<button (click)="pagination.prevPage()" [disabled]="!pagination.hasPrev()">← Précédent</button>
<span> Page {{ pagination.currentPage() }} / {{ pagination.totalPages() }} </span>
<button (click)="pagination.nextPage()" [disabled]="!pagination.hasNext()">Suivant →</button>
</nav>
`,
})
export class UsersListComponent implements OnInit {
// TypeScript infère automatiquement Signal<User[]> pour items
protected readonly pagination = usePagination<User>('/api/users', 20);
ngOnInit(): void {
this.pagination.loadPage(1); // Déclenche le chargement initial
}
}
DI et testing : mocks type-safe
L'un des plus grands bénéfices concrets de la DI Angular est la testabilité. Remplacer des services réels par des mocks type-safe est trivial grâce au système de providers — voici les patterns essentiels.
Mocker un service HTTP avec TestBed
// user.service.spec.ts — test de service avec HTTP mocké
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { UserService, User } from './user.service';
describe('UserService', () => {
let service: UserService; // Type précis — autocomplétion dans les tests
let httpMock: HttpTestingController; // Interception des requêtes HTTP
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule], // Remplace HttpClient par un mock
providers: [UserService],
});
service = TestBed.inject(UserService); // Type inféré : UserService
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify()); // Vérifie qu'aucune requête n'est en attente
it('devrait retourner la liste des utilisateurs', () => {
const mockUsers: User[] = [
{ id: 1, name: 'Alice', email: 'alice@test.com', role: 'admin', createdAt: new Date() },
{ id: 2, name: 'Bob', email: 'bob@test.com', role: 'user', createdAt: new Date() },
];
service.getAll().subscribe(users => {
expect(users.length).toBe(2); // Comparaison sur User[]
expect(users[0].name).toBe('Alice'); // Accès type-safe à user.name
});
// Intercepter et répondre à la requête HTTP
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('GET');
req.flush(mockUsers); // Répondre avec les données de test
});
});
Mocker avec des stubs type-safe (approche interface)
// profile.component.spec.ts — test de composant avec mock de service
import { TestBed } from '@angular/core/testing';
import { ProfileComponent } from './profile.component';
import { UserService, User } from './user.service';
import { of } from 'rxjs';
// Partial<jest.Mocked<UserService>> — mock type-safe des méthodes utilisées
const userServiceMock: Partial<jest.Mocked<UserService>> = {
getAll: jest.fn().mockReturnValue(of([])), // Observable vide type-safe
getById: jest.fn().mockReturnValue(of({ id: 1, name: 'Test', email: 'test@test.com', role: 'user', createdAt: new Date() } as User)),
getByRole: jest.fn().mockReturnValue(of([])),
};
describe('ProfileComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ProfileComponent],
providers: [
{
provide: UserService,
useValue: userServiceMock, // Injection du stub — pas du vrai service
},
],
});
});
it('devrait appeler getAll au démarrage', () => {
const fixture = TestBed.createComponent(ProfileComponent);
fixture.detectChanges(); // Déclenche ngOnInit
expect(userServiceMock.getAll).toHaveBeenCalledTimes(1);
});
});
Tester un InjectionToken de configuration
// header.component.spec.ts — fournir une config de test via InjectionToken
import { TestBed } from '@angular/core/testing';
import { HeaderComponent } from './header.component';
import { API_CONFIG, ApiConfig } from './tokens';
describe('HeaderComponent', () => {
const testConfig: ApiConfig = {
baseUrl: 'https://api.test.com',
timeout: 1000,
retryAttempts: 1,
};
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [HeaderComponent],
providers: [
{
provide: API_CONFIG,
useValue: testConfig, // Config de test — isolée de la prod
},
],
});
});
it('devrait afficher l\'URL de l\'API', () => {
const fixture = TestBed.createComponent(HeaderComponent);
fixture.detectChanges();
const nav = fixture.nativeElement.querySelector('nav');
expect(nav.textContent).toContain('api.test.com'); // URL de test, pas prod
});
});
Bonnes pratiques et checklist
Voici une synthèse des règles à respecter pour une DI Angular robuste, scalable et maintenable — organisées par niveau de maîtrise.
Pour les juniors — les fondamentaux à maîtriser
- Utiliser
providedIn: 'root'pour tous les services globaux (Auth, HTTP, Store) - Préférer
inject()au constructeur dans les nouveaux composants Angular 14+ - Toujours annoter les types de retour des services — bannir
any - Centraliser les
InjectionTokendans un fichiertokens.tsdédié - Appeler
httpMock.verify()dansafterEachdes tests HTTP - Nommer les tokens en SCREAMING_SNAKE_CASE pour les distinguer des classes
Pour les experts — aller plus loin
- Créer des services génériques (
GenericCrudService<T>) pour éliminer la duplication - Combiner
inject()+ Signals pour des composables réutilisables entre composants - Scoper les services aux routes lazy-loaded (
providersdansloadComponent) - Utiliser
satisfies(TypeScript 4.9+) plutôt queaspour valider les objets de config - Définir des interfaces pour les services — facilite les mocks et les migrations
- Inspecter la hiérarchie avec Angular DevTools (onglet "Injector Tree")
Erreurs fréquentes à éviter absolument
inject() ne peut être appelé que durant la phase d'initialisation de la classe (propriété de classe ou constructeur). L'appeler dans ngOnInit ou dans une méthode de classe génère une erreur runtime non détectable à la compilation.
// ❌ Erreur runtime — inject() appelé hors du contexte DI
@Component({ /* ... */ })
class BadComponent implements OnInit {
private service!: MyService;
ngOnInit(): void {
// ERREUR : "inject() must be called from an injection context"
this.service = inject(MyService);
}
}
// ✅ Correct — inject() en propriété de classe (contexte DI valide)
@Component({ /* ... */ })
class GoodComponent {
// Initialisé dans le contexte d'injection — avant ngOnInit
private readonly service = inject(MyService);
}
ERROR: Cannot instantiate cyclic dependency!. La solution canonique : extraire la logique partagée dans un troisième service C, dont dépendent A et B sans se référencer mutuellement.
// ❌ Dépendance circulaire — A → B → A
@Injectable({ providedIn: 'root' })
class ServiceA {
private b = inject(ServiceB); // ServiceA dépend de ServiceB
}
@Injectable({ providedIn: 'root' })
class ServiceB {
private a = inject(ServiceA); // ServiceB dépend de ServiceA → CYCLE!
}
// ✅ Solution : extraire la logique commune dans SharedService
@Injectable({ providedIn: 'root' })
class SharedService {
doSharedWork(): void { /* logique partagée extraite */ }
}
@Injectable({ providedIn: 'root' })
class ServiceA {
private shared = inject(SharedService); // A → Shared (pas de cycle)
}
@Injectable({ providedIn: 'root' })
class ServiceB {
private shared = inject(SharedService); // B → Shared (pas de cycle)
}