Directives are a powerful feature worth mastering to sharpen your Angular skills. While TypeScript enforces strict types and helps keep your codebase robust, custom directives are not fully typed by default. This article demonstrates how to bring strict typing to your structural directives, making your Angular application more resilient.


To enable type checking, we rely on two useful type guards provided by Angular:

  • ngTemplateContextGuard: Lets you define a custom type for the context passed to your directive’s template.
  • ngTemplateGuard_[customInputProperty]: Narrows the type of an input property for the rendered template.

Before exploring these Angular-specific static functions, it helps to understand how TypeScript type predicates work. If you are unfamiliar with them or need a refresher, consider reading this guide; otherwise, feel free to proceed.


ngTemplateContextGuard

When you want to supply a typed context to your custom directive, the static function ngTemplateContextGuard ensures that context is properly typed within the template. It behaves like a TypeScript type guard, returning a type predicate.
Let’s examine an example to clarify how this works.

interface DemoUrl {
  url: string;
  video: boolean;
}

// interface declaring the Context of this Directive
interface DemoContext {
  $implicit: number;
  demo: string;
  url: DemoUrl;
}

@Directive({
  selector: '[demo]',
  standalone: true,
})
export class DemoDirective implements OnInit {
  @Input() demo!: string;
  @Input() demoUrl!: DemoUrl;

  constructor(
    private readonly viewContainerRef: ViewContainerRef,
    private readonly templateRef: TemplateRef<DemoContext>
  ) {}

  ngOnInit(): void {
    const context = {
      $implicit: 1,
      demo: this.demo,
      url: this.demoUrl,
    };
    this.viewContainerRef.createEmbeddedView(this.templateRef, context);
  }

  // Guard to help Typescript correctly type checked 
  // the context with which the template will be rendered
  static ngTemplateContextGuard(
    directive: DemoDirective,
    context: unknown
  ): context is DemoContext {
    return true;
  }
}
Enter fullscreen mode Exit fullscreen mode

The ngTemplateContextGuard returns true since this directive will always pass a context of type DemoContext to the template.

When this directive is used in a template, the properties from that context become correctly typed, giving you reliable autocompletion and compile-time checks.

proper typing

Pro tip: there are three syntaxes for writing structural directives, all of which the compiler treats identically.

<ng-template demo="toto" [demoUrl]="demoUrl" let-version let-demo="demo" let-url="url">
  {{ url.url }}
</ng-template>

// * is the shorthand for what angular will interpret with ng-template
<div *demo="'toto'; let version; url: demoUrl; let demo = demo; let url = url">
  {{ url.url }}
</div>

// We can use "as" in replacement of "let ... = "
<div *demo="'toto' as version; url: demoUrl; demo as demo; url as url">
  {{ url.url }}
</div>
Enter fullscreen mode Exit fullscreen mode

ngTemplateGuard_[customInputProperty]

This guard is slightly trickier to grasp. A structural directive decides when a template appears in the DOM. (For instance, NgIf only adds the template when its input condition evaluates to truthy.)

If your custom directive accepts a complex input type but only renders the template under specific circumstances, you can refine the type in the template using this guard.

Consider the following scenario:

// Typescript type guard
export const isDog = (animal: Animal): animal is Dog => {
  return (animal as Dog).breed !== undefined;
}

interface Cat {
  name: string;
  type: 'cat';
}

interface Dog {
  name: string;
  race: string;
  type: 'dog';
}

type Animal = Dog | Cat;

@Directive({
  selector: '[isDog]',
  standalone: true,
})
export class DogDirective {
  @Input('isDog') set isDogInput(animal: Animal) {
    if (isDog(animal.type)) {
      this.viewContainerRef.createEmbeddedView(this.templateRef);
    } else {
      this.viewContainerRef.clear();
    }
  }

  constructor(
    private readonly viewContainerRef: ViewContainerRef,
    private readonly templateRef: TemplateRef<unknown>
  ) {}
Enter fullscreen mode Exit fullscreen mode

This directive takes an Animal as input and renders the template only when that animal is a Dog. Even though we are certain the template only appears with a Dog, the animal variable inside the template is still typed as Animal.

without ngTemplateGuard

Here is where ngTemplateGuard proves invaluable. By adding the following function, we narrow the type to Dog, unlocking better type safety in the template.

static ngTemplateGuard_isDog(
  dir: DogDirective,
  state: Animal // input type
): state is Dog { // output type
  return true;
}
Enter fullscreen mode Exit fullscreen mode

Now the type in the template is inferred correctly.

with ngTemplateGuard

Note: if you rename the input binding in the template, like @Input('isDogExternal') isDog, the guard uses the external name. This means the guard would become ngTemplateGuard_isDogExternal.


And that’s it! There’s no longer any reason to leave your custom directives without strict typing.

I hope this introduced you to a useful Angular concept. If you enjoyed it, you can connect with me on Medium, Twitter, or GitHub.

👉 And if you want to speed up your Angular learning, check out Angular Challenges.