NgTemplateOutlet Type Checking

with @ContentChild

Thomas Laforge
7 min readDec 6, 2022

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>

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 looks 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>;
}

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 personTemplateRefis 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 {}
<person [person]="person">
<!-- #personRef has been replaced with person (PersonDirective selector) -->
<ng-template person let-name let-age="age">
{{ name }}: {{ age }}
</ng-template>
</person>

In our #PersonComponent, we can now look up this directive reference by writing:

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

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;
}
}

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>

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;

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;
}
}
}

Our previous code now gets a compiled error:

ListComponent: Type is unknown

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;
}
}

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;
}
}

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;
}
}

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

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>

If you enjoyed this article, I invite you to read the next part:

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

If you found this article useful, please consider supporting my work by giving it some claps👏👏 to help it reach a wider audience. Your support would be greatly appreciated.

👉 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! Don’t hesitate to ping me if you have more questions

--

--

Thomas Laforge

Software Engineer | GoogleDevExpert in Angular 🅰️ | #AngularChallenges creator