~6 min de lecture
Guards, Resolvers, Interceptors Angular : 5 raisons d'arrêter d'écrire des classes
Tu ouvres un projet Angular un peu ancien. Tu tombes sur ça :
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
constructor(
private auth: AuthService,
private router: Router,
private logger: Logger,
private store: Store,
) {}
canActivate(): boolean | UrlTree {
// 12 lignes de logique
}
}
Quatre dépendances dans le constructeur, un décorateur, une interface à implémenter, une classe complète pour ce qui aurait pu tenir en une fonction de huit lignes. Multiplie par cinq guards, trois resolvers, deux interceptors : tu te retrouves avec dix classes qui font un seul truc chacune, toutes enregistrées avec leur tax de boilerplate.
Depuis Angular 14.2 pour les guards et resolvers, et Angular 15 pour les interceptors, tout ça est obsolète. Les classes ne sont pas dépréciées au sens strict, mais elles sont devenues le mauvais choix par défaut : plus de code, moins de tree-shaking, tests plus lourds, composition pénible. Voici 5 raisons concrètes de basculer, avec à chaque fois le before/after.
Valide Angular 14.2+ pour les guards/resolvers, 15+ pour les interceptors. Recommandé sur tout projet 17+.
TL;DR
| Pattern | Classe | Fonction |
|---|---|---|
| Guard | implements CanActivate |
CanActivateFn |
| Resolver | implements Resolve<T> |
ResolveFn<T> |
| Interceptor | implements HttpInterceptor |
HttpInterceptorFn |
| Provider | { provide: HTTP_INTERCEPTORS, useClass, multi: true } |
withInterceptors([...]) |
| Injection | constructor params | inject() dans le corps |
| Test | TestBed.configureTestingModule + spy |
appel direct dans runInInjectionContext |
La règle d'or : si ton guard, resolver ou interceptor n'a pas d'état interne mutable, c'est une fonction.
Raison 1 : moins de boilerplate, lecture linéaire
La classe te force à séparer l'injection (constructeur) de la logique (méthode). Tu lis le constructeur en haut, tu scrolles pour la logique, tu remontes vérifier le nom de la dépendance, tu re-scrolles. Pour huit lignes utiles.
La fonction met tout au même endroit. Tu lis du haut vers le bas, sans yo-yo visuel.
Avant
import { Injectable } from '@angular/core';
import { CanActivate, Router, UrlTree } from '@angular/router';
import { AuthService } from './auth.service';
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
constructor(
private readonly auth: AuthService,
private readonly router: Router,
) {}
canActivate(): boolean | UrlTree {
if (this.auth.isLoggedIn()) {
return true;
}
return this.router.createUrlTree(['/login']);
}
}
Après
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isLoggedIn()) {
return true;
}
return router.createUrlTree(['/login']);
};
Pas de décorateur, pas d'interface, pas de classe. Le type CanActivateFn te garantit la signature exacte (params ActivatedRouteSnapshot et RouterStateSnapshot, retour MaybeAsync<GuardResult>). Si tu te trompes, TypeScript hurle avant le runtime.
Côté route, l'enregistrement est identique :
{ path: 'admin', canActivate: [authGuard], loadComponent: () => import('./admin/admin') }
Raison 2 : tree-shaking qui marche vraiment
Une classe @Injectable({ providedIn: 'root' }) est référencée par son token. Le compilateur Angular sait qu'elle existe mais ne peut pas toujours prouver qu'elle est utilisée : si elle est référencée dans une string, dans une route lazy, ou via un useClass quelque part dans les providers, elle est embarquée dans le bundle.
Une fonction exportée est un import standard. Si personne ne l'importe, esbuild ou Rspack la dégagent automatiquement. Si une seule route l'utilise, elle se retrouve dans le chunk de cette route, pas dans le bundle initial.
Concrètement, sur un projet avec une dizaine de guards et resolvers, j'ai mesuré une réduction du initial.js de 18 kB minifiés en passant en fonctionnel. Ce n'est pas spectaculaire, mais c'est gratuit, et c'est récurrent à chaque build.
L'autre effet de bord positif : tu peux co-localiser le guard avec la feature qui l'utilise sans craindre qu'il soit chargé partout. Plus besoin d'un dossier guards/ partagé qui pollue le bundle de chaque route.
Raison 3 : composition native et factories paramétrées
Avec les classes, dès que tu veux paramétrer un guard ("vérifier le rôle X, le rôle Y"), tu te retrouves à créer une roleGuard classe avec une factory de provider, ou à lire le rôle depuis route.data. Verbeux.
Avec les fonctions, tu fais une factory qui retourne une CanActivateFn. Tu paramètres au call site, en gardant un typage fort.
import { CanActivateFn, Router } from '@angular/router';
import { inject } from '@angular/core';
import { UserStore } from './user.store';
export const roleGuard = (allowed: ReadonlyArray<Role>): CanActivateFn => {
return () => {
const user = inject(UserStore).user();
const router = inject(Router);
if (user && allowed.includes(user.role)) {
return true;
}
return router.createUrlTree(['/forbidden']);
};
};
Au call site :
export const routes: Routes = [
{
path: 'admin',
canActivate: [authGuard, roleGuard(['admin', 'superadmin'])],
loadComponent: () => import('./admin/admin'),
},
{
path: 'reports',
canActivate: [authGuard, roleGuard(['admin', 'analyst'])],
loadComponent: () => import('./reports/reports'),
},
];
Tu chaînes plusieurs guards : Angular les exécute dans l'ordre, et la première réponse falsy ou UrlTree annule la navigation. Aucune classe à enregistrer, aucun provider à déclarer pour la version paramétrée.
Pour les interceptors, la composition est encore plus claire. Avant, tu jonglais avec HTTP_INTERCEPTORS et l'ordre dépendait de l'ordre des providers, ce qui te forçait à un fichier core.providers.ts central :
Avant
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: RetryInterceptor, multi: true },
Après
provideHttpClient(
withInterceptors([authInterceptor, loggingInterceptor, retryInterceptor]),
)
L'ordre est lisible, l'API est typée, le tableau peut être composé à partir de plusieurs fichiers feature.
Raison 4 : tests sans TestBed (ou presque)
Tester un guard classe, c'est du folklore : TestBed.configureTestingModule, mocks dans les providers, TestBed.inject(MonGuard), puis appel de la méthode. Une dizaine de lignes pour vérifier un if.
Tester un guard fonctionnel, c'est un appel direct dans un contexte d'injection. Tu peux toujours utiliser TestBed pour fournir les dépendances, mais l'API runInInjectionContext te permet d'invoquer la fonction directement.
Avant
describe('AuthGuard', () => {
let guard: AuthGuard;
let auth: { isLoggedIn: ReturnType<typeof vi.fn> };
let router: { createUrlTree: ReturnType<typeof vi.fn> };
beforeEach(() => {
auth = { isLoggedIn: vi.fn() };
router = { createUrlTree: vi.fn() };
TestBed.configureTestingModule({
providers: [
AuthGuard,
{ provide: AuthService, useValue: auth },
{ provide: Router, useValue: router },
],
});
guard = TestBed.inject(AuthGuard);
});
it('lets logged-in users through', () => {
auth.isLoggedIn.mockReturnValue(true);
expect(guard.canActivate()).toBe(true);
});
});
Après
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { AuthService } from './auth.service';
import { authGuard } from './auth.guard';
describe('authGuard', () => {
it('lets logged-in users through', () => {
TestBed.configureTestingModule({
providers: [
{ provide: AuthService, useValue: { isLoggedIn: () => true } },
{ provide: Router, useValue: {} },
],
});
const result = TestBed.runInInjectionContext(() =>
authGuard({} as never, {} as never),
);
expect(result).toBe(true);
});
});
Tu gagnes les beforeEach lourds et la création explicite du guard. Si tu pousses plus loin avec un Injector créé à la main, tu peux même te passer de TestBed complètement.
Bonus : les factories paramétrées sont encore plus simples à tester. Tu appelles roleGuard(['admin']) directement, tu compares le résultat sans rituel.
Raison 5 : cohérence avec le reste d'Angular moderne
Les API modernes d'Angular convergent toutes vers le même pattern : provider functions (provideRouter, provideHttpClient, provideAnimationsAsync), inject() pour la DI, fonctions pour les configurations. Continuer à écrire des guards classe, c'est créer un îlot d'ancien style au milieu d'une codebase qui pousse partout vers le fonctionnel.
Le contexte d'injection est partagé. inject() marche dans une CanActivateFn, dans un HttpInterceptorFn, dans un effect(), dans une factory de provider. Tu apprends l'API une fois, tu l'utilises partout.
Pour les interceptors, la convergence va plus loin avec HttpContext : tu peux passer des métadonnées par requête, et lire dans l'interceptor fonctionnel via inject().
import { HttpContextToken, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
export const SKIP_AUTH = new HttpContextToken<boolean>(() => false);
export const authInterceptor: HttpInterceptorFn = (req, next) => {
if (req.context.get(SKIP_AUTH)) {
return next(req);
}
const token = inject(AuthService).getToken();
if (!token) return next(req);
return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
};
Au call site :
this.http.get('/public', { context: new HttpContext().set(SKIP_AUTH, true) });
Cette composition inject() + HttpContext est nettement plus claire qu'un interceptor classe qui devrait soit accepter une option dans le constructeur, soit lire un service de configuration.
Et les resolvers, alors ?
Les ResolveFn<T> suivent exactement le même pattern. Si tu fais encore du data resolving (et tu devrais reconsidérer la question avec httpResource et les signals, mais c'est un autre débat), la version fonctionnelle est aussi limpide.
import { inject } from '@angular/core';
import { ResolveFn, Router } from '@angular/router';
import { ArticleApi } from './article.api';
import type { Article } from './article.model';
export const articleResolver: ResolveFn<Article> = (route) => {
const slug = route.paramMap.get('slug');
if (!slug) {
inject(Router).navigate(['/404']);
throw new Error('Missing slug');
}
return inject(ArticleApi).getBySlug(slug);
};
Au call site :
{ path: 'article/:slug', resolve: { article: articleResolver }, loadComponent: () => import('./article/article') }
Pas de classe, pas de décorateur, pas de provider à ajouter. Si la fonction n'est importée nulle part, elle disparaît du bundle.
Récap actionnable
Avant de pousser ta prochaine PR :
- Cherche tes guards classes :
grep -r "implements CanActivate" src/. Pour chacun, remplace par uneconst xxxGuard: CanActivateFnavecinject()dans le corps. - Cherche tes resolvers classes :
grep -r "implements Resolve" src/. Même transformation avecResolveFn<T>. - Cherche tes interceptors classes :
grep -r "implements HttpInterceptor" src/. Remplace parHttpInterceptorFn, et bascule l'enregistrement surwithInterceptors([...]). - Vire les providers obsolètes : les
HTTP_INTERCEPTORS multi: truene servent plus à rien une fois tous tes interceptors passés en fonctionnel. - Refactore tes tests : remplace
TestBed.inject(MonGuard)parTestBed.runInInjectionContext(() => guardFn(...)). Tu gagnes de la lisibilité et tu peux supprimer lesbeforeEachredondants. - Paramètre ce qui doit l'être : si tu as plusieurs guards qui ne diffèrent que par un rôle ou un flag, fusionne-les en une factory
(arg) => CanActivateFn.
Les classes guards/resolvers/interceptors ne sont pas un anti-pattern fatal, mais elles sont un signal clair que le projet n'a pas suivi la trajectoire d'Angular moderne. Sur une codebase qui vise les signals, le zoneless, et les APIs provide*, ces classes détonnent. Quinze minutes par fichier suffisent à les faire disparaître.
Envie d'aller plus loin sur Angular moderne ? Rejoins-nous : https://pim.ms/gjvOEOb