How to Achieve Type Safety with Angular ngTemplateOutlet for Union Types
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
ngTemplateContextGuardwhen 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 (
@ifand@for) for optimal change detection and readability.