Front-end angularforall.com

- Angular DI avancée : type-safety junior & expert

Angular Dependency Injection Type-Safety Typescript Inject
Angular DI avancée : type-safety junior & expert

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 :

  1. Recherche le provider correspondant dans l'injecteur courant
  2. Remonte la hiérarchie si le token n'est pas trouvé
  3. Instancie la dépendance (ou retourne une instance existante si singleton)
  4. 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.

Règle fondamentale : En Angular moderne, tout ce que vous injectez doit avoir un type explicite. Bannissez 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();
}
Convention : Nommez vos tokens en SCREAMING_SNAKE_CASE pour les distinguer des classes. La description passée au constructeur ('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
        ],
    },
];
Piège courant : Si vous déclarez un service à la fois dans 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
    });
});
Bonne pratique : Définissez toujours des interfaces pour vos services métier et injectez les tokens d'interface plutôt que les classes concrètes. Cela rend le remplacement dans les tests trivial et garantit un découplage parfait entre la logique métier et les détails d'implémentation.

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 InjectionToken dans un fichier tokens.ts dédié
  • Appeler httpMock.verify() dans afterEach des 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 (providers dans loadComponent)
  • Utiliser satisfies (TypeScript 4.9+) plutôt que as pour 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

Erreur #1 — inject() hors du contexte d'injection : 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);
}
Erreur #2 — Dépendance circulaire : ServiceA injecte ServiceB qui injecte ServiceA → Angular lance 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)
}

Ressources pour approfondir

Partager