Understanding the Type Safety Challenge with ngTemplateOutlet

When working with Angular's ngTemplateOutlet, passing dynamic context—such as either a single object or an array of objects—frequently leads to type inference issues. By default, Angular's template type checker struggles to infer implicit context variables when context types are union types like Row | Row[], often falling back to any or throwing strict template checking errors when attempting array operations like Array.isArray() inside template expressions.

Solution 1: Use a Custom Context Guard Directive

The most robust way to enforce strict typing on ngTemplateOutlet context is by creating a directive that leverages Angular's ngTemplateContextGuard static method. This informs Angular's Language Service and compiler about the exact shape of your template context.

import { Directive, Input } from '@angular/core';

export interface RowContext<T> {
  $implicit: T | T[];
  index: number;
}

@Directive({
  selector: '[appRowOutlet]',
  standalone: true
})
export class RowOutletDirective<T> {
  @Input() appRowOutletContext!: RowContext<T>;

  static ngTemplateContextGuard<T>(
    dir: RowOutletDirective<T>,
    ctx: unknown
  ): ctx is RowContext<T> {
    return true;
  }
}

Solution 2: Refactor into Separate Template Directives or Components

Mixing array logic and single object rendering inside a single template context often violates the Single Responsibility Principle. A cleaner architectural approach in modern Angular (v17+) is to separate the rendering logic into distinct templates or reusable sub-components.

Instead of checking Array.isArray(row) directly in HTML, handle array rendering and single-row rendering through dedicated sub-templates or computed Signals:

<tbody>
  @if (isMultiSelectMode()) {
    @for (group of multiSelectRows(); track $index) {
      <ng-container *ngTemplateOutlet="multiRowTpl; context: { $implicit: group, index: $index }"></ng-container>
    }
  } @else {
    @for (row of rows(); track $index) {
      <ng-container *ngTemplateOutlet="singleRowTpl; context: { $implicit: row, index: $index }"></ng-container>
    }
  }
</tbody>

<!-- Single Row Template -->
<ng-template #singleRowTpl let-row let-i="index">
  <tr>
    <td>{{ row.searchField.fieldName }}</td>
  </tr>
</ng-template>

<!-- Multi Row Template -->
<ng-template #multiRowTpl let-rows let-i="index">
  <tr>
    <td>
      @for (item of rows; track $index) {
        <span>{{ item.searchField.fieldName }}</span>
      }
    </td>
  </tr>
</ng-template>

Solution 3: Type Guard Helper Functions in Component

If you prefer keeping a unified template, avoid evaluating complex expressions like Array.isArray() directly inside the template. Instead, define type guard methods or computed Signals within your component TypeScript file:

import { Component, signal, computed } from '@angular/core';

@Component({
  selector: 'app-dynamic-table',
  standalone: true,
  templateUrl: './dynamic-table.component.html'
})
export class DynamicTableComponent {
  rows = signal<TableRow[]>([]);
  multiSelectRows = signal<TableRow[][]>([]);

  isRowArray(row: TableRow | TableRow[]): row is TableRow[] {
    return Array.isArray(row);
  }
}

Summary

  • Strict Typing: Use ngTemplateContextGuard when using structural directives to guarantee strong type checking.
  • Clean Architecture: Prefer separate <ng-template> blocks for arrays and single items to simplify template logic and enhance maintainability.
  • Performance: Leverage modern Angular Signals and template control flow (@if and @for) for optimal change detection and readability.