In Angular, why are class-based route guards deprecated, and how do you migrate one to a functional guard that still uses injected services?
answer
- boilerplate class versus plain function
- deprecated in route arrays since v15.2
- runInInjectionContext with the route injector
- mapToCanActivate as a bridge
- inject() before the first await
basics
~10 sSince v15.2, listing an @Injectable class or InjectionToken in a route's guard arrays is deprecated. Rewrite it as a CanActivateFn that calls inject() synchronously, or wrap the class with mapToCanActivate([MyGuard]) while migrating.
solid answer
~40 sClass guards needed an `@Injectable` class implementing `CanActivate`, a provider, and a class token in `canActivate`; passing classes or `InjectionToken`s in route guard arrays was deprecated in v15.2 in favour of plain functions. The router calls a functional guard inside `runInInjectionContext` with the route's environment injector, so `inject(AuthService)` works in the function body and sees route-level `providers`. To migrate, I move the `canActivate` method body into a `CanActivateFn` and replace constructor parameters with `inject()` calls at the top. If the class is too big to rewrite now, `mapToCanActivate([AdminGuard])` (or `() => inject(AdminGuard).canActivate(...)`) adapts it; the class then still needs to be injectable. Functions also compose: a factory like `hasRole('admin')` returns a `CanActivateFn`. The trap: `inject()` only works synchronously, so calling it after an `await` throws NG0203.
code
ts · 19 linesimport { inject } from '@angular/core';
import { CanActivateFn, Router, Routes, mapToCanActivate } from '@angular/router';
import { AuthService } from './auth.service';
import { LegacyAuditGuard } from './legacy-audit.guard';
export const hasRole = (role: string): CanActivateFn => async () => {
const auth = inject(AuthService);
const router = inject(Router); // inject everything before the first await
const roles = await auth.loadRoles();
return roles.includes(role) ? true : router.createUrlTree(['/forbidden']);
};
export const routes: Routes = [
{
path: 'admin/audit',
canActivate: [hasRole('admin'), ...mapToCanActivate([LegacyAuditGuard])],
loadComponent: () => import('./admin/audit-log').then((m) => m.AuditLog),
},
];go deeper
Recall that new guards are plain functions typed CanActivateFn and similar, and that they get services with inject().
Explain the injection context the router creates with the route's environment injector, the NG0203 trap after await, and the mapToCanActivate adapter.
Plan an incremental migration of a large guard suite, using factories to collapse duplicated classes and adapters for the rest.
Set the team rule for guard style and composition so role checks live in one reusable factory rather than scattered classes.
## How class guards looked Before functional guards, a guard in Angular was an **`@Injectable` class** implementing an interface such as `CanActivate`, with the decision in a method and dependencies arriving through the constructor. The route listed the class itself, `canActivate: [AdminGuard]`, and the router asked the injector for an instance. Codebases also used `InjectionToken`s whose provider was a guard function, and even string tokens. In v15.2 Angular deprecated passing **class and `InjectionToken` guards (and resolvers)** in route configuration, recommending plain functions that obtain dependencies with `inject()` from `@angular/core`. In the current source the union member for those tokens is the `@deprecated` type `DeprecatedGuard` (`ProviderToken<any> | string`). The interfaces themselves (`CanActivate`, `CanDeactivate`, `CanMatch`, `CanActivateChild`) are still exported, which is what `mapToCanActivate` and friends consume. ## Why functions won | Concern | Class guard | Functional guard | |---|---|---| | Boilerplate | Class, decorator, interface, method | One exported function | | Registration | Must be injectable and provided | Nothing to provide | | Parameterising | Needs `route.data` or a token per variant | A factory: `hasRole('admin')` | | Composition | Inheritance or extra services | Call one function from another | | Dependencies | Constructor parameters | `inject()` in the body | The parameterisation row is the one interviewers like: a function that returns a `CanActivateFn` replaces a family of near-identical classes. ## Why inject() works inside a guard `inject()` only works inside an **injection context**. The router creates one for each guard call: for `canActivate`, `canActivateChild`, `canDeactivate` and `canMatch` it calls the function through `runInInjectionContext(injector, ...)`, where `injector` is the **environment injector of the route being checked**. Consequences: - Services provided in the route's own `providers` array, or in a lazily loaded route config's providers, are visible to that route's guards. - Root services such as `Router` are visible as usual. - The context lasts only for the **synchronous** part of the call. After an `await`, or inside a `subscribe` callback, the context is gone and `inject()` throws **NG0203** ("`inject()` must be called from an injection context"). So an `async` guard injects everything it needs on its first lines, then awaits. ## Migrating step by step 1. Create a function typed with the matching guard type, for example `export const adminGuard: CanActivateFn = (route, state) => { ... }`. 2. Replace each constructor parameter with a `const x = inject(X);` at the top of the function. 3. Move the old method body in, keeping its return values: `boolean`, `UrlTree`, `RedirectCommand` or an `Observable`/`Promise` of one. 4. Change the route to `canActivate: [adminGuard]` and delete the class and its provider. 5. For a class you cannot rewrite yet, use the adapter: `canActivate: mapToCanActivate([AdminGuard])`. It maps each class to `(...params) => inject(AdminGuard).canActivate(...params)`. `mapToCanMatch`, `mapToCanActivateChild`, `mapToCanDeactivate` and `mapToResolve` exist for the other kinds. The class must still be injectable, typically with `providedIn: 'root'`. ## Guard factories Because a guard is just a function, a function can build one: - `hasRole(role)` returns a `CanActivateFn` that checks that role. - Several conditions can be combined in one guard when their order matters, instead of relying on array order. - Factories keep the inputs visible in the route table (`canActivate: [hasRole('admin')]`) rather than hidden in `route.data`. ## Common migration mistakes - **Injecting inside callbacks.** Moving `this.auth.user$.pipe(map(...))` into a function and calling `inject(Router)` inside `map` fails with NG0203; capture `const router = inject(Router)` first and use the variable in the callback. - **Keeping `providedIn` noise.** After a rewrite, a leftover `@Injectable({ providedIn: 'root' })` class that nothing references is dead code; delete it with the route change. - **Mixing the two forms by accident.** A route with `canActivate: [AdminGuard]` (a class) still compiles through the deprecated union member, so search the route tables for class references rather than trusting the build. - **Losing parameterisation.** A class that read `route.data['role']` becomes clearer as `hasRole('admin')`, but only if every route's `data` entry is migrated with it. - **Assuming order.** A guard factory that combines checks is the place to express ordering; separate guards in one array are started together. ## Testing note A functional guard is easy to call in a test with `TestBed.runInInjectionContext(() => adminGuard(route, state))`, though full router-level testing with `RouterTestingHarness` belongs to the testing topic.
- In an Angular functional guard, why does inject(Router) throw NG0203 when it is called after an await?The router runs the guard inside `runInInjectionContext`, and that context exists only while the function executes synchronously. Code after `await` resumes in a later microtask, outside the context, so `inject()` has no injector to use. Inject every dependency at the top of the guard, before the first `await`.
- In Angular, can a functional guard use a service that is provided only in its route's providers array?Yes. The router passes the environment injector of the route being checked to `runInInjectionContext`, and a route with `providers` gets its own environment injector. The guard's `inject()` resolves from there, then up through parent route injectors to the root.
saying these in an interview costs you the question
- Functional guards cannot use dependency injection, so services must be passed in manually.
- The CanActivate interface is removed, so mapToCanActivate no longer works.
- inject() can be called anywhere inside an async guard, even after an await.
- A functional guard runs in the root injector, so route-level providers are invisible to it.
- Class guards are still the recommended way to write guards in new code.