DEV Community

Cover image for How to Write a Custom Structural Directive in Angular - Part 2
Chris Trześniewski for This Dot

Posted on • Originally published at thisdot.co

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 article first.

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
Enter fullscreen mode Exit fullscreen mode

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[] = [];
}
Enter fullscreen mode Exit fullscreen mode

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);
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
      });
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
      });
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
      });
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 */
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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!


This Dot Labs is a development consultancy focused on providing staff augmentation, architectural guidance, and consulting to companies.

We help implement and teach modern web best practices with technologies such as React, Angular, Vue, Web Components, GraphQL, Node, and more.

Discussion (0)