NgTemplateOutlet Typed checked (with @ContentChild)
thomas
Posted on December 7, 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.
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>;
}
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 {}
<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 theread
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
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 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;
}
}
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>
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!
Posted on December 7, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.