Strongly Typed ngTemplateOutlet

Generics, ngTemplateContextGuard, Directives

ng-template-outlet-cover
portrait-image
The ngTemplateOutlet can be used to project content into a component and therefore generic components such as tables commonly use them. But achieving strong typing is not trivial at all and needs some little TypeScript tricks.

Loosely Typed Table

Below, you can see the code for a very basic generic table that leverages the ngTemplateOutlet to be reusable for various use-cases.

<div class="overflow-x-auto relative">
  <table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-fixed">
    <thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
      <tr>
        <ng-container
          *ngTemplateOutlet="tableHead"
        ></ng-container>
      </tr>
    </thead>
    <tbody>
      <tr *ngFor="let row of data" class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
        <ng-container
          *ngTemplateOutlet="tableRow; context: { $implicit: row }"
        ></ng-container>
      </tr>
    </tbody>
  </table>
</div>
table.component.html
export class TableComponent {
  @Input() data!: any;
  @ContentChild('tableHead') tableHead!: TemplateRef<any>;
  @ContentChild('tableRow') tableRow!: TemplateRef<any>;
}
table.component.ts

You might already have spotted a few code-smells. Such as the any types. Or the static string selector for the ContentChild.

1. Generics

First of all, let's remove the any type of data. In order to do so, we can use a generic type and make sure that the type is an array of objects, by using the following code. We know, that T is an object and then the type of data is an array of this type. TypeScript is pretty smart when it comes to infering types from generics, such that we do not have to do anything in addition. Just by assigning a value to data will infer the type of T.

export class TableComponent<T extends object> {
  @Input() data!: T[];
  @ContentChild('tableHead') tableHead!: TemplateRef<any>;
  @ContentChild('tableRow') tableRow!: TemplateRef<any>;
}
table.component.ts

2. Directives

Another code smell is the static string in the ContentChild which selects the child from the template. Obvisously, this is error prone, because a refactoring in the template will not refactor this string. Or just a simple typo in the string would not emit an immediate error.

But we can use a custom directive to select the child such that we get rid of the string selector. Therefore, I created a directive for each ContentChild.

@Directive({
  selector: 'ng-template[appTableHead]'
})
export class TableHeadDirective {
  constructor() { }
}
table-head.directive.ts
export class TableComponent<T extends object> {
  @Input() data!: T[];
  @ContentChild(TableHeadDirective, {read: TemplateRef}) tableHead!: TemplateRef<any>;
  @ContentChild(TableRowDirective, {read: TemplateRef}) tableRow!: TemplateRef<any>;
}  
table.component.ts

3. ngTemplateContextGuard

Although, this code is already a lot cleaner, there is one major flaw present in the ng-template in the component that is instantiating the table. The implicit variable passed through the ngTemplateOutletContext is of type any, hence accessing properties inside the ng-template is not typed at all and therefore very error prone. This should be fixed, but that is definitely the trickiest part of this refactoring.

row: any
row: any

In order to infer the right type, we can use the directive that we already created and use a combination of generics and a static method called ngTemplateContextGuard. This method uses a type predicate with the is keyword and is used to guard the type of the context.

interface Row<T extends object> {
  $implicit: T
}

@Directive({
  selector: 'ng-template[appTableRow]'
})
export class TableRowDirective<T extends object> {
  @Input() appTableRow!: T[];

  constructor() { }

  static ngTemplateContextGuard<TContext extends object>(
    directive: TableRowDirective<TContext>,
    context: unknown
  ): context is Row<TContext> {
    return true;
  }

}
table-row.directive.ts
<app-table [data]="persons">
  <ng-template [appTableHead]>
    <td scope="col" class="py-3 px-6">Firstname</td>
    <td scope="col" class="py-3 px-6">Lastname</td>
    <td scope="col" class="py-3 px-6">Age</td>
  </ng-template>
  <ng-template [appTableRow]="persons" let-row>
    <td class="py-4 px-6"></td>
    <td class="py-4 px-6"></td>
    <td class="py-4 px-6"></td>
  </ng-template>
</app-table>
app.component.html

By adding the input to the directive with the generic type, the directive is aware of the type that is passed to the table. This is neccesary to infer the type of the context variable using the static context guard that is used to assert a type using a type predicate. Since the context guard is a static method it can not simply reuse the generic type of the directive and has to define its very own generic type.

Shout out to Joshua Morony

This blog post is strongly inspired by Joshua Moronys really awesome YouTube video on this exact issue. I recommend checking it out: Joshua Moronys YouTube video

GitHub Repository

Comments