NgTemplateOutlet Typed checked (with @ContentChild)

achtlos

thomas

Posted on December 7, 2022

NgTemplateOutlet Typed checked (with @ContentChild)

Welcome to Angular challenges #4.

The aim of this series of Angular challenges is to increase your skills by practicing on real life exemples. Moreover you can submit your work though a PR which I or other can review; as you will do on real work project or if you want to contribute to Open Source Software.

The fourth challenge will teach us how to use the ngTemplateOutlet structural directive of Angular with strong typing.

If you haven't done the challenge yet, I invite you to try it first by going to Angular Challenges and coming back afterward to compare your solution with mine. (You can also submit a PR that I'll review)


In this challenge we start with two components, PersonComponent and ListComponent where we can customize their 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

Despite the fact that this example works perfectly at runtime, we are not taking full advantage of Typescript at compile time. As you can see below, there is no typing, so this code is not safe for future refactoring or development. 

IDE type inference for PersonComponent

IDE type inference for ListComponent

Before moving on to solving this problem, I invite you to consult these articles to understand Directive Type Checking and Typescript Type Guard


PersonComponent: Type is known in advance

Our PersonComponent look like this:

@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

We get the template reference from our parent component view via @ContentChild with a magic string (#personRef). We inject the template into ngTemplateOutlet to display it. If personTemplateRef is not defined, we display a backup template. 

@ContentChild is a decorator used to access elements or directives that are projected into the component template. This is the difference with @ViewChild which is used to access elements or directives defined in the template.

We can apply what we learned in our Directive Type Checking article. 

First, we need to create a Directive and replace #personRef with its selector.

// 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 our PersonComponent, we can now look up this directive reference by writing:

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

read lets us defined which element of the DOM we want to target. Without the read meta property, we will get PersonDirective as return type. 

The template is still of unknown type, but since we now referring to a directive, we can take advantage of ngTemplateContextGuard and set 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

And now let's the IDE works for us

IDE type inference with strong typing

Bonus: Typing NgTemplateOutlet

There is still one problem, the ngTemplateOutlet directive itself is not strongly typed (at the time of writing). So in our template defining your outlet context, typing is still not present.

 

<!-- 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

When looking at the source code of NgTemplateOutlet, we can clearly see that the context input property overrides our type with Object or null.

 

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

If we want to have correct typing, we can always create our own TemplateOutlet directive by copying and pasting the Angular core Directive and applying typing on it.

 

@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

Our previous code now gets a compiled error: 

correct type error

ListComponent: Type is unkwown

Compared to PersonComponent, we don't know the type in advance. This is a bit trickier.

In the same way as before, let's create a directive and add a ngTemplateContextGuard to it.

 

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

type inference for ListComponent

We haven't improved our typing much. Only index property is correctly typed to number. 

Let us use Typescript generic types to specify our context type:

 

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

To give our directive the type of our list, we need to pass this type to our directive. In Angular, the only way to give this information at compile time is through Inputs.

 

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

Now, let's rewrite our template to pass this input. 

correct type inference

And here you go, we have strongly typed properties. 

Bonus tip:

We can also write our template with the shorthand syntax *:

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

I hope you enjoyed this fourth challenge and learned from it.

👉 Other challenges are waiting for you at Angular Challenges. Come and try them. I'll be happy to review you!

Follow me on Medium, Twitter or Github to read more about upcoming Challenges!

💖 💪 🙅 🚩
achtlos
thomas

Posted on December 7, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related