Angular Challenge #4

This series of Angular challenges is meant to sharpen your skills through practical examples. You can submit your solution via a PR for review, mirroring how you would collaborate on a real project or in open source.

Challenge number four focuses on leveraging the ngTemplateOutlet structural directive of Angular with full type safety.

If you haven't attempted it yet, try solving it first on Angular Challenges, then return here to compare your approach with mine. (PRs are always welcome.)


Our starting point includes two components — PersonComponent and ListComponent — both offering customizable templates.

 

<person [person]="person">
  <ng-template #personRef let-name let-age="age">
    {{ name }}: {{ age }}
  </ng-template>
</person>

<list [list]="students">
  <ng-template #listRef let-student let-i="index">
    {{ student.name }}: {{ student.age }} - {{ i }}
  </ng-template>
</list>

<list [list]="cities">
  <ng-template #listRef let-city let-i="index">
    {{ city.name }}: {{ city.country }} - {{ i }}
  </ng-template>
</list>
Enter fullscreen mode Exit fullscreen mode

Although this implementation runs correctly at runtime, we're missing out on what Typescript offers at compile time. As shown below, no typing exists, leaving this code vulnerable to future refactoring or extension.

IDE type inference for PersonComponent

IDE type inference for ListComponent

Before jumping into the solution, review these write-ups to grasp Directive Type Checking and Typescript Type Guard.


PersonComponent: Type Known Upfront

The current state of PersonComponent is as follows:

@Component({
  standalone: true,
  imports: [NgTemplateOutlet],
  selector: 'person',
  template: `
    <ng-container
      *ngTemplateOutlet="
        personTemplateRef || emptyRef;
        context: { $implicit: person.name, age: person.age }
      "></ng-container>

    <ng-template #emptyRef> No Template </ng-template>
  `,
})
export class PersonComponent {
  @Input() person!: Person;

  @ContentChild('#personRef', { read: TemplateRef })
  personTemplateRef?: TemplateRef<unknown>;
}
Enter fullscreen mode Exit fullscreen mode

The template reference is pulled from the parent component's view using @ContentChild, identified by the string (#personRef). We hand that template to ngTemplateOutlet for rendering. When personTemplateRef is absent, a fallback template is shown.

@ContentChild provides access to elements or directives projected into the component. This distinguishes it from @ViewChild, which targets elements or directives in the component's own template.

Now we apply the insights from our type-checking article.

We start by defining a Directive with a selector that will stand in for #personRef.

// This directive seems unnecessary, but it's always better to reference a directive 
// than a magic string. And we will see that it can be very useful
@Directive({
  selector: 'ng-template[person]',
  standalone: true,
})
export class PersonDirective {}
Enter fullscreen mode Exit fullscreen mode
<person [person]="person">
  <!-- #personRef has been replaced with person (PersonDirective selector) -->
  <ng-template person let-name let-age="age">
    {{ name }}: {{ age }}
  </ng-template>
</person>
Enter fullscreen mode Exit fullscreen mode

In PersonComponent, we can then request the directive reference as follows:

@ContentChild(PersonDirective, { read: TemplateRef })
personTemplateRef?: TemplateRef<unknown>;
Enter fullscreen mode Exit fullscreen mode

The read option determines which DOM element we target. Without read, the return type would be PersonDirective.

While the template's type remains generic, since we now reference a directive, we can use ngTemplateContextGuard to define our context.

 

interface PersonContext {
  $implicit: string;
  age: number;
}

@Directive({
  selector: 'ng-template[person]',
  standalone: true,
})
export class PersonDirective {
  static ngTemplateContextGuard(
    dir: PersonDirective,
    ctx: unknown
  ): ctx is PersonContext {
    return true;
  }
}
Enter fullscreen mode Exit fullscreen mode

Now let's let the IDE do the work for us.

IDE type inference with strong typing

Bonus: Typing NgTemplateOutlet

Yet another issue remains: the ngTemplateOutlet directive itself lacks strong typing (at the time of this writing). Consequently, defining the outlet context in your template doesn't come with type checks.

 

<!-- should give a compile error since $implicit wants a string, and context 
is looking for $implicit and age -->
<ng-container
  *ngTemplateOutlet="
    personTemplateRef || emptyRef;
    context: { $implicit: person.age }
  "></ng-container>
Enter fullscreen mode Exit fullscreen mode

Inspecting the NgTemplateOutlet source reveals that the context input overrides our type with Object or null.

 

Input() public ngTemplateOutletContext: Object|null = null;
Enter fullscreen mode Exit fullscreen mode

For proper typing, one option is to build a custom TemplateOutlet directive by duplicating Angular's internal directive and adding strong types.

 

@Directive({
  selector: '[ngTemplateOutlet]',
  standalone: true,
})
// The directive is now waiting for a specific Type. 
export class AppTemplateOutlet<T> implements OnChanges {
  private _viewRef: EmbeddedViewRef<T> | null = null;

  @Input() public ngTemplateOutletContext: T | null = null;

  @Input() public ngTemplateOutlet: TemplateRef<T> | null = null;

  @Input() public ngTemplateOutletInjector: Injector | null = null;

  constructor(private _viewContainerRef: ViewContainerRef) {}

  ngOnChanges(changes: SimpleChanges) {
    if (changes['ngTemplateOutlet'] || changes['ngTemplateOutletInjector']) {
      const viewContainerRef = this._viewContainerRef;

      if (this._viewRef) {
        viewContainerRef.remove(viewContainerRef.indexOf(this._viewRef));
      }

      if (this.ngTemplateOutlet) {
        const {
          ngTemplateOutlet: template,
          ngTemplateOutletContext: context,
          ngTemplateOutletInjector: injector,
        } = this;
        this._viewRef = viewContainerRef.createEmbeddedView(
          template,
          context,
          injector ? { injector } : undefined
        ) as EmbeddedViewRef<T> | null;
      } else {
        this._viewRef = null;
      }
    } else if (
      this._viewRef &&
      changes['ngTemplateOutletContext'] &&
      this.ngTemplateOutletContext
    ) {
      this._viewRef.context = this.ngTemplateOutletContext;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

With this approach, our earlier code surfaces a compilation error: 

correct type error

ListComponent: The Unknown Type Challenge

The PersonComponent case was straightforward since its type was known ahead of time. For ListComponent, the type cannot be determined upfront, which makes the situation more complex.

As before, the solution starts with a directive that includes a ngTemplateContextGuard.

interface ListTemplateContext {
  $implicit: any[]; // we don't know the type in advance
  appList: any[];
  index: number; // we know that index will always be of type number
}

@Directive({
  selector: 'ng-template[appList]',
  standalone: true,
})
export class ListTemplateDirective {
  static ngTemplateContextGuard(
    dir: ListTemplateDirective,
    ctx: unknown
  ): ctx is ListTemplateContext {
    return true;
  }
}

type inference for ListComponent

This approach barely improves our type safety — the index property is the only one that gets properly typed as number.

To refine this, TypeScript generic types can be leveraged to define our context type explicitly.

interface ListTemplateContext<T> {
  $implicit: T;
  appList: T;
  index: number;
}

@Directive({
  selector: 'ng-template[appList]',
  standalone: true,
})
// T is still unknown. 
// Angular can only infer the correct type by referring to the type of inputs
export class ListTemplateDirective<T> {
  static ngTemplateContextGuard<TContext>(
    dir: ListTemplateDirective<TContext>,
    ctx: unknown
  ): ctx is ListTemplateContext<TContext> {
    return true;
  }
}

For the directive to understand the shape of our list, that type information must be supplied to it. In Angular, compile-time type information can only be passed through Input properties.

@Directive({
  selector: 'ng-template[appList]',
  standalone: true,
})
export class ListTemplateDirective<T> {
  @Input('appList') list!: T[]

  static ngTemplateContextGuard<TContext>(
    dir: ListTemplateDirective<TContext>,
    ctx: unknown
  ): ctx is ListTemplateContext<TContext> {
    return true;
  }
}

The template is then updated to feed this input.

correct type inference

This results in fully typed properties, exactly as intended.

A Useful Extra

The template can also be expressed using the shorthand * syntax.

<list [list]="students">
  <ng-container *appList="students as student; index as i">
    {{ student.name }}: {{ student.age }} - {{ i }}
  </ng-container>
</list>

This final challenge wraps up the series. I trust you found it both enjoyable and instructive.

👉 Additional challenges are available at Angular Challenges. Feel free to participate — I look forward to reviewing your solutions!

Keep up with me on Medium, Twitter, or Github for updates on future challenges!