Mert Özen Further With Every Line
Frontend Angular — #23

Angular 15: Functional Guards, Functional Interceptors, hostDirectives and NgOptimizedImage

Mert Özen Oct 31, 2026 7 min 2 views
Angular 15: Functional Guards, Functional Interceptors, hostDirectives and NgOptimizedImage

The second half of Angular 15: functional guards with CanActivateFn, functional interceptors with withInterceptors, composing directives with hostDirectives, and NgOptimizedImage with ngSrc.

Version note: This post covers what Angular 15 introduced; the code targets Angular 15.

From Classes to Functions

In the first half of Angular 15 we set up the standalone backbone. In this post we look at four features that sit on top of it: guards written as functions, interceptors also written as functions, hostDirectives for combining several directives in a single component, and NgOptimizedImage, which makes image loading smarter.

The first two share a story. In Angular 12, writing a guard or an interceptor meant opening a class, implementing an interface and taking dependencies through the constructor. Thanks to inject(), which arrived in Angular 14, that ceremony is no longer needed; most of the time a single function is enough.

Which Version Do These Features Need?

Everything in this post is part of Angular 15, but not all of it arrived in 15.0. Guard types like CanActivateFn and withInterceptors are there in 15.0; CanLoad was deprecated in 15.1 and class-based guards in 15.2. So make sure you are on the latest 15 minor:

ng update @angular/core@15 @angular/cli@15
npm ls @angular/core

If you are already on 15, this command brings you to the latest 15.x release. Node and TypeScript requirements are the same as in the previous post: Node 14.20, 16.13 or 18.10 and later, and at least TypeScript 4.8.

Our Guard in Angular 12

Remember the class-based guard we saw earlier. A classic example that sends users who are not logged in from the account page to login:

@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
  constructor(private auth: AuthService, private router: Router) { }

  canActivate(): boolean | UrlTree {
    return this.auth.isLoggedIn() ? true : this.router.createUrlTree(['/login']);
  }
}
{ path: 'account', component: AccountComponent, canActivate: [AuthGuard] }

The heart of it is one line: is the user logged in, and if not, redirect to login. But for that one line we wrote a class, a decorator, an interface and a constructor with two parameters. We just want to tell the doorman "let in anyone with a badge", yet first we have to hire him as a full-time employee.

Functional Guards: CanActivateFn

The same guard in Angular 15:

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);
  return auth.isLoggedIn() ? true : router.createUrlTree(['/login']);
};
{ path: 'account', component: AccountComponent, canActivate: [authGuard] }

The guard is now a plain function. The CanActivateFn type defines the parameters the function receives (route and state) and the values it may return; if you do not need the parameters, you do not have to write them. We get dependencies with inject(). Because the router provides an injection context when it runs the guard, inject() works fine here.

Being a function has a nice side effect: guards that take parameters become very easy to write. You can produce a role-checking guard like this:

export function roleGuard(role: string): CanActivateFn {
  return () => inject(AuthService).hasRole(role);
}
{ path: 'admin', component: AdminComponent, canActivate: [roleGuard('admin')] }

In the class-based world, doing this meant putting the role in the route's data field and reading it inside the guard. Here the function receives what it needs directly as a parameter.

Let's also be clear about one thing: functional guards are not limited to standalone apps. They work the same way in an NgModule-based project that uses RouterModule.forRoot.

Other Guards and Resolvers

The same approach applies to the other kinds of guards and resolvers. A CanDeactivate guard that warns about unsaved changes:

export interface HasUnsavedChanges {
  hasUnsavedChanges(): boolean;
}

export const unsavedChangesGuard: CanDeactivateFn<HasUnsavedChanges> = (component) =>
  component.hasUnsavedChanges()
    ? confirm('Kaydedilmemiş değişiklikler var. Sayfadan çıkılsın mı?')
    : true;

A resolver that loads data before the route opens:

export const productResolver: ResolveFn<Product> = (route) =>
  inject(ProductService).getById(route.paramMap.get('id')!);
{
  path: 'products/:id',
  component: ProductDetailComponent,
  resolve: { product: productResolver },
  canDeactivate: [unsavedChangesGuard]
}

The CanLoad guard we used for lazy loading was deprecated in 15.1; the recommended replacement is CanMatch, which arrived in Angular 14.1, and its function form is CanMatchFn. Angular 15.2 then marked class-based and InjectionToken-based guards and resolvers as deprecated. Your existing classes still work, but the message is clear: the guards you write from now on should be functions.

Functional Interceptors: HttpInterceptorFn

Remember the interceptor we saw earlier that adds a token to every request:

@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  constructor(private auth: AuthService) { }

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const authReq = req.clone({ setHeaders: { Authorization: `Bearer ${this.auth.token}` } });
    return next.handle(authReq);
  }
}
providers: [
  { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
]

In Angular 15:

import { inject } from '@angular/core';
import { HttpInterceptorFn } from '@angular/common/http';
import { AuthService } from './auth.service';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(AuthService).token;
  return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
};
bootstrapApplication(AppComponent, {
  providers: [
    provideHttpClient(withInterceptors([authInterceptor, errorInterceptor]))
  ]
});

The interceptor is a function: it receives the request and next, which represents the next step. Instead of next.handle(req) we call next(req) directly. Registration got simpler too; instead of the HTTP_INTERCEPTORS token and multi: true, we pass an array to the provideHttpClient we saw in the previous post, using withInterceptors. The order in the array is the order in which interceptors run on the way out. Think of a parcel conveyor belt: the package passes each station in turn, and each station sticks a label on it and sends it to the next.

An Interceptor That Catches Errors

Let's also write the errorInterceptor from the list above. It should send the user to the login page whenever a request returns 401:

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const router = inject(Router);

  return next(req).pipe(
    catchError((error: HttpErrorResponse) => {
      if (error.status === 401) {
        router.navigate(['/login']);
      }
      return throwError(() => error);
    })
  );
};

There is an important detail here: we called inject(Router) at the very top of the function, not inside catchError. While the interceptor function runs, we are inside an injection context; but the callback we pass to catchError runs much later, when the response arrives, and by then that context is gone. Put inject() inside the callback and you get the "injection context" error we saw earlier. The rule is simple: grab what you need first, then build the stream. One small note as well: the throwError(() => error) form belongs to RxJS 7; if your project is still on RxJS 6, you write throwError(error).

The Directive Composition API: hostDirectives

Say you have a directive that logs a record for analytics whenever a button is clicked:

@Directive({
  selector: '[appTrackClick]',
  standalone: true
})
export class TrackClickDirective {
  @Input() trackId = '';

  @HostListener('click') onClick(): void {
    console.log(`click: ${this.trackId}`);
  }
}

In Angular 12 you had to add it to every button by hand: <app-button appTrackClick trackId="save">. Whenever someone forgot it somewhere, that button's clicks were never recorded. With Angular 15 you can attach the directive to the component itself:

@Component({
  selector: 'app-button',
  standalone: true,
  template: `<button type="button"><ng-content></ng-content></button>`,
  hostDirectives: [
    { directive: TrackClickDirective, inputs: ['trackId'] }
  ]
})
export class ButtonComponent { }
<app-button trackId="save-profile">Kaydet</app-button>

Now every app-button carries TrackClickDirective on its own. Like a car that leaves the factory with air conditioning; buyers do not have to get it fitted separately.

There are a few rules. A directive added to hostDirectives has to be standalone. The directive's inputs and outputs are not exposed by default; you must explicitly list the ones you want to be bindable from templates in the inputs and outputs arrays. Because we listed trackId, we could set it on app-button. Also, this composition happens at compile time; you cannot add or remove a directive at runtime based on a condition. The real power of this API is combining several directives in the same array and building rich components out of small, single-purpose pieces.

NgOptimizedImage and ngSrc

In Angular 12, images were ordinary img tags and performance was entirely up to you:

<img src="/assets/hero.jpg" alt="Kampanya görseli">
<img src="/assets/products/keyboard.jpg" alt="Mekanik klavye" loading="lazy">

With the NgOptimizedImage directive, stable as of Angular 15:

<img ngSrc="/assets/hero.jpg" alt="Kampanya görseli" width="1200" height="500" priority>
<img ngSrc="/assets/products/keyboard.jpg" alt="Mekanik klavye" width="400" height="300">

To use it, import NgOptimizedImage from @angular/common and add it to the imports array of your component or module. The moment you write ngSrc instead of src, the directive kicks in and pushes you toward good habits.

width and height are required; if they are missing, you get an error in development mode. That way the browser reserves space for the image before it arrives and content does not jump down while the page loads. For images whose size you do not know in advance and that should fill their container, there is also a fill mode. Images without priority are lazy-loaded by default; you do not need to write loading="lazy" yourself. When you add priority to the page's main image, that image is requested with high priority. If you forget priority on your main image, a warning in the development console reminds you.

If you serve your images from a CDN like Imgix or Cloudinary, ready-made loaders such as provideImgixLoader or provideCloudinaryLoader also give you ngSrcset support, so the right image size is requested for each screen size. The first version of this directive arrived in Angular 14.2 under the name rawSrc; in Angular 15 it was renamed to ngSrc and became stable.

A Small Experiment

In your project, turn a class-based guard into a function and use the function's name instead of the class name in the route definition. Then add a roleGuard('admin') and see how a guard with parameters works. Next, rewrite your token interceptor as an HttpInterceptorFn and register it with withInterceptors; check in the browser's Network tab that the Authorization header is still sent.

Also make a mistake on purpose once: move the inject(Router) line in errorInterceptor inside catchError, trigger a request that returns 401 and read the error. Finally, update the largest image on your page with ngSrc, width, height and priority, and check that the loading="lazy" attribute shows up on its own in the HTML of the other images.

Angular 15 laid the groundwork for the move from classes to functions. In the next version, Angular offers a developer preview of a feature that fundamentally changes how it thinks about reactivity: the signals that came with Angular 16, meaning signal, computed and effect, and how they live alongside RxJS.