Skip to content

How to Write a Custom Structural Directive in Angular - Part 2

This article was written over 18 months ago and may contain information that is out of date. Some content may be relevant but please refer to the relevant official documentation or available resources for the latest information.

How to write a custom structural directive in Angular - part 2

In the previous article I've shown how you can implement a custom structural directive in Angular. We've covered a simple custom structural directive that implements interface similar to Angular's NgIf directive. If you don't know what structural directives are, or are interested in basic concepts behind writing custom one, please read the previous articlefirst.

In this article, I will show how to create a more complex structural directive that:

  • passes properties into the rendered template
  • enables strict type checking for the template variables

Starting point

I am basing this article on the example implemented in the part 1 article. You can use example on Stackblitz as a starting point if you wish to follow along with the code examples.

Custom NgForOf directive

This time, I would like to use Angular's NgForOf directive as an example to re-implement as a custom CsdFor directive. Let's start off by using Angular CLI to create a new module, and directive files:

ng generate module for
ng generate directive for/for --module for

# or shorthand
# ng g m for
# ng g d for/for --module for

First, we need to follow similar steps as with the CsdIf directive.

  • add constructor with TemplateRef, and ViewContainerRef injected
  • add an @Input property to hold the array of items that we want to display
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';

@Directive({
  selector: '[csdFor]',
})
export class ForDirective<T> {
  constructor(
    private templateRef: TemplateRef<unknown>,
    private vcr: ViewContainerRef
  ) {}

  @Input() csdForOf: T[] = [];
}

Then, in the ngOnInit hook we can render all the items using the provided template:

export class ForDirective<T> implements OnInit {
  private items: T[] = [];

  constructor(
    private templateRef: TemplateRef<unknown>,
    private vcr: ViewContainerRef
  ) {}

  @Input() csdForOf: T[] = [];

  ngOnInit(): void {
    this.renderItems();
  }

  private renderItems(): void {
    this.vcr.clear();
    this.csdForOf.map(() => {
      this.vcr.createEmbeddedView(this.templateRef);
    });
  }
}

Now, we can verify that it displays the items properly by adding the following template code to our AppComponent.

<div *csdFor="let item of [1, 2, 3, 4, 5]">
  <p>This is item</p>
</div>

It displays the items correctly, but doesn't allow for changing the displayed collection yet. To implement that, we can modify the csdForOf property to be a setter and rerender items then:

export class ForDirective<T> {
  private items: T[] = [];

  constructor(
    private templateRef: TemplateRef<unknown>,
    private vcr: ViewContainerRef
  ) {}

  @Input() set csdForOf(items: T[]) {
    this.items = items;
    this.renderItems();
  }

  private renderItems(): void {
    this.vcr.clear();
    this.items.map(() => {
      this.vcr.createEmbeddedView(this.templateRef);
    });
  }
}

Now, our custom directive will render the fresh items every time the collection changes (its reference).

Accessing item property

The above example works nice already, but it doesn't allow us to display the item's content yet. The following code will display "no content" for each template rendered.

<div *csdFor="let item of [1, 2, 3, 4, 5]">
  <p>This is item: {{ item || '"no content"' }}</p>
</div>
csdFor no content

To resolve this, we need to provide a value of each item into a template that we are rendering. We can do this by providing second param to createEmbeddedView method of ViewContainerRef.

export class ForDirective<T> {
  /* rest of the class */

  private renderItems(): void {
    this.vcr.clear();
    this.items.map((item) => {
      this.vcr.createEmbeddedView(this.templateRef, {
        // provide item value here
      });
    });
  }
}

The question is what key do we provide to assign it under item variable in the template. In our case, the item is a default param, and Angular uses a reserved $implicit key to pass that variable. With that knowledge, we can finish the renderItems method:

export class ForDirective<T> {
  /* rest of the class */

  private renderItems(): void {
    this.vcr.clear();
    this.items.map((item) => {
      this.vcr.createEmbeddedView(this.templateRef, {
        $implicit: item,
      });
    });
  }
}

Now, the content of the item is properly displayed:

csdFor with content

Adding more variables to the template's context

Original NgForOf directives allows developers to access a set of useful properties on an item's template:

  • index - the index of the current item in the collection.
  • count - the length of collection
  • first - true when the item is the first item in the collection
  • last - true when the item is the last item in the collection
  • even - true when the item has an even index in the collection
  • odd - true when the item has an odd index in the collection

We can pass those as well when creating a view for a given element along with the $implicit parameter:

export class ForDirective<T> {
  /* rest of the class */

  private renderItems(): void {
    this.vcr.clear();
    this.items.map((item, index, arr) => {
      this.vcr.createEmbeddedView(this.templateRef, {
        $implicit: item,
        index,
        first: index === 0,
        last: index === arr.length - 1,
        even: (index & 1) === 0,
        odd: (index & 1) === 1,
        count: arr.length,
      });
    });
  }
}

And now, we can use those properties in our template.

<div
  *csdFor="
        let item of [1, 2, 3, 4, 5];
        let i = index;
        let isFirst = first;
        let isLast = last;
        let isEven = even;
        let isOdd = odd;
        let size = count
      "
>
  <p>This is item: {{ item }}.</p>
  <pre>
        Index: {{ i }}
        First: {{ isFirst }}
        Last: {{ isLast }}
        Even: {{ isEven }}
        Odd: {{ isOdd }}
        Count: {{ size }}
      </pre
  >
</div>
csdFor with additional props

Improve template type checking

Lastly, as a developer using the directive it improves, the experience if I can have type checking in the template used by csdFor directive. This is very useful as it will make sure we don't mistype the property name as well as we only use the item, and additional properties properly. Angular's compiler allows us to define a static ngTemplateContextGuard methods on a directive that it will use to type-check the variables defined in the template. The method has a following shape:

static ngTemplateContextGuard(
  dir: DirectiveClass,
  ctx: unknown): ctx is DirectiveContext {
    return true;
}

This makes sure that the properties of template rendered by our DirectiveClass will need to conform to DirectiveContext. In our case, this can be the following:

interface ForDirectiveContext<T> {
  $implicit: T;
  index: number;
  first: boolean;
  last: boolean;
  even: boolean;
  odd: boolean;
  count: number;
}

@Directive({
  selector: '[csdFor]',
})
export class ForDirective<T> {
  static ngTemplateContextGuard<T>(
    dir: ForDirective<T>,
    ctx: unknown
  ): ctx is ForDirectiveContext<T> {
    return true;
  }

  /* rest of the class */
}

Now, if we eg. try to access item's property that doesn't exist on the item's interface, we will get a compilation error:

<div *csdFor="let item of [1, 2, 3, 4, 5]">
  <p>This is item: {{ item.someProperty }}.</p>
</div>
csdFor item compilation error

The same would happen if we made a typo in any of the context property names:

<div *csdFor="let item of [1, 2, 3, 4, 5]; let isFirst = firts">
  <p>This is item: {{ item }}.</p>
</div>
csdFor context variable typo

Summary

In this article, we've created a clone of Angular's built-in NgForOf directive. The same approach can be used to create any other custom directive that your project might need. As you can see, implementing a custom directive with additional template properties and great type checking experience is not very hard.

If something was not clear, or you want to play with the example directive, please visit the example on Stackblitz.

In case you have any questions, you can always tweet or DM me at @ktrz. I'm always happy to help!

Let's innovate together!

We're ready to be your trusted technical partners in your digital innovation journey.

Whether it's modernization or custom software solutions, our team of experts can guide you through best practices and how to build scalable, performant software that lasts.

Prefer email? hi@thisdot.co