skip to content

In Angular, why are class-based route guards deprecated, and how do you migrate one to a functional guard that still uses injected services?

level: middleimportance: should knowfreq 50%

answer

  1. boilerplate class versus plain function
  2. deprecated in route arrays since v15.2
  3. runInInjectionContext with the route injector
  4. mapToCanActivate as a bridge
  5. inject() before the first await

basics

~10 s

Since 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 s

Class 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 lines
ts
import { 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

for a junior

Recall that new guards are plain functions typed CanActivateFn and similar, and that they get services with inject().

for a middle

Explain the injection context the router creates with the route's environment injector, the NG0203 trap after await, and the mapToCanActivate adapter.

for a senior

Plan an incremental migration of a large guard suite, using factories to collapse duplicated classes and adapters for the rest.

for a principal

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.