Template comments, spread syntax, arrow functions, and stricter type checking that catches bugs before they ship
Ever tried to leave a quick note in an Angular template — just a one-line reminder for future you — and given up because there was no clean way to do it without breaking the compiler? Small annoyances like that pile up over the years, and Angular 22 quietly clears out a handful of them at once.
This isn't a flashy release for templates. There's no new control-flow block, no new directive. What you get instead is a set of small, practical improvements that make templates read more like the TypeScript you already write, plus a stricter compiler that catches mistakes it used to wave through. In this article we'll go through all of it hands-on. By the end you'll know:
- How to actually write comments inside your templates without weird workarounds
- How object spread, array spread, and rest arguments now work in template expressions
- How to use arrow functions directly in event bindings and
@forloops - What
strictTemplatesbeing on by default means for your existing codebase - How exhaustive
@switchchecks catch missing cases at compile time - How to write tests that exercise these template features, not just describe them
If you've read my other pieces on this release — the stable Resource API and the router changes — this one rounds things out with the part of Angular 22 that shows up in literally every component you write. If this is the first one you've landed on, don't worry, it stands on its own.
Before we get into the code: if small, practical Angular deep dives like this are useful to you, following me here means you catch the next one instead of relying on the algorithm to surface it for you.
Before we dive into the examples, a quick note: the code snippets provided here are meant purely for understanding the concept. Some syntax shown may reflect patterns from earlier Angular versions. Always refer to the official documentation for the most current API and syntax.
Comments inside HTML elements
Until this release, you could comment out a chunk of template with <!-- -->, but you couldn't drop a comment inside an element's attribute list the way you'd comment a function call in TypeScript. Angular 22 adds support for both block and line comments directly inside an element definition.
<div
// primary button, styled from the design system
class="btn btn-primary"
/*
Note: disabled state only reflects the loading signal,
not the form's overall validity.
*/
[disabled]="loading()"
>
Save changes
</div>
This is a small thing, but it matters most in exactly the situation shown above: a long attribute list where a binding's intent isn't obvious from its name alone. Instead of hunting through a component's TypeScript file to figure out why [disabled] is wired up the way it is, the explanation lives right where you're reading the template.
Spread syntax and rest arguments in templates
Template expressions can now use object spread, array spread, and rest arguments — syntax that used to be limited to your TypeScript classes.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
@Component({
selector: 'app-tag-list',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div [class]="{ ...baseClasses, active: isActive() }">
@for (tag of [...preferredTags, ...extraTags]; track tag) {
<span class="tag">{{ tag }}</span>
}
</div>
<p>Total: {{ sum(...scores()) }}</p>
`,
})
export class TagList {
protected readonly baseClasses = { card: true, rounded: true };
protected readonly isActive = signal(true);
protected readonly preferredTags = ['angular', 'signals'];
protected readonly extraTags = ['templates', 'v22'];
protected readonly scores = signal([10, 25, 40]);
protected sum(...values: number[]): number {
return values.reduce((total, value) => total + value, 0);
}
}
Three things are happening in that snippet, all of them things you'd normally reach for a component method to do:
-
{ ...baseClasses, active: isActive() }merges a static class map with a reactive one, directly in the[class]binding. -
[...preferredTags, ...extraTags]combines two arrays inline for the@forloop, instead of precomputing a merged array as a class property. -
sum(...scores())spreads a signal's array value straight into a function call.
None of these are things you couldn't do before — you'd just have written a getter or a computed signal to do the merging in TypeScript instead. This syntax mostly saves you from writing small, single-use helper properties whose only job was working around a template limitation.
Arrow functions in event bindings and loops
Template expressions can now include arrow functions with an implicit return, which is genuinely useful inside @for loops where you want to call a method with something specific to the current item.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
interface Task {
id: number;
title: string;
done: boolean;
}
@Component({
selector: 'app-task-list',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (task of tasks(); track task.id) {
<label>
<input
type="checkbox"
[checked]="task.done"
(change)="toggle((t) => t.id === task.id)"
/>
{{ task.title }}
</label>
}
`,
})
export class TaskList {
protected readonly tasks = signal<Task[]>([
{ id: 1, title: 'Write the article', done: false },
{ id: 2, title: 'Review the code samples', done: false },
]);
protected toggle(matches: (task: Task) => boolean): void {
this.tasks.update((current) =>
current.map((task) => (matches(task) ? { ...task, done: !task.done } : task)),
);
}
}
A couple of constraints to keep in mind: arrow functions with a block body — (x) => { ... } — aren't supported in templates, and you can't use a pipe inside the arrow function's body. The compiler is also smart about identity: an arrow function that only touches its own parameters gets hoisted to the module level so it isn't recreated on every change detection cycle, while one that closes over the template context is kept stable per view. You don't have to think about this while writing the template, but it's good to know your event bindings aren't secretly reallocating functions on every render.
While we're on the topic — have you found other spots where an inline arrow function actually reads better than pulling logic into a component method, or do you still prefer keeping templates as logic-free as possible? I go back and forth on this one myself.
Stricter type checking, on by default
Angular 22 turns strictTemplates on by default, so you get full strict type checking in templates without adding anything to tsconfig.json. If you're upgrading an older project, a migration adds strictTemplates: false automatically to preserve your existing behavior — worth flipping back on deliberately once you've had a chance to fix whatever it surfaces.
Two new compile-time checks come along with this tightening. The first catches an element matched by more than one component:
[ERROR] NG8023: Multiple components match node with tagname pr-menu: 'Menu', 'OtherMenu'. [plugin angular-compiler]
That used to be a runtime surprise. Now it's a build failure, which is a much better place for it to live. The second catches inputs, outputs, or models that collide under an alias:
@Component({ /* ... */ })
export class ProfileCard {
readonly user = input('');
readonly user2 = input('', { alias: 'user' });
}
✘ [ERROR] NG1054: Input 'user' is bound to both 'user' and 'user2'. [plugin angular-compiler]
Exhaustive @switch checks
Pairing well with stricter type checking, @switch can now enforce that every case of a union type is handled, using @default never.
@Component({
selector: 'app-session-banner',
template: `
@switch (state) {
@case ('loggedOut') { <button>Log in</button> }
@case ('loading') { <p>Checking your session…</p> }
@case ('loggedIn') { <p>Welcome back!</p> }
@default never;
}
`,
})
export class SessionBanner {
protected state: 'loggedOut' | 'loading' | 'loggedIn' = 'loggedOut';
}
If someone later adds 'banned' to that union without adding a matching @case, the compiler flags it immediately instead of letting the new state silently fall through to nothing being rendered. Angular 22 extends this to discriminated unions on a nested property, using never(expression) to tell the compiler exactly which union it should check for full coverage:
@Component({
selector: 'app-panel',
template: `
@switch (state.mode) {
@case ('show') { <p>Menu item {{ state.menu }}</p> }
@case ('hide') {}
@default never(state);
}
`,
})
export class Panel {
protected state!: { mode: 'show'; menu: number } | { mode: 'hide' };
}
This kind of check is exactly the sort of thing that's easy to skip in a code review — a new variant added to a type definition three files away from the template that renders it — and exhaustiveness checking turns that into a build error instead of a silent gap in the UI.
One more type-checking improvement worth knowing: optional chaining in templates now follows the same narrowing rules as regular TypeScript, so this compiles cleanly in Angular 22 without needing a redundant ?. on the second access:
@if (user?.profile?.name) {
<p>{{ user.profile.name }}</p>
}
Previously, the compiler couldn't tell that the @if guard already ruled out user or user.profile being null, so you had to repeat the safe-navigation operator even where it wasn't logically necessary. If you're upgrading from an older version, be aware that project?.author used to evaluate to null when project was nullish, but now follows JavaScript semantics and evaluates to undefined instead — the migration wraps existing expressions in a $safeNavigationMigration() helper automatically so this change doesn't silently alter behavior in code that specifically checked for null.
Testing template behavior, not just describing it
These are compiler-level features, so the most useful tests aren't testing the syntax directly — they're testing that the component behaves the way the template implies it should. Here's a test for the task list from earlier, confirming the arrow-function-based toggle actually flips the right item.
import { TestBed } from '@angular/core/testing';
import { TaskList } from './task-list';
describe('TaskList', () => {
it('toggles only the task that was clicked', () => {
const fixture = TestBed.createComponent(TaskList);
fixture.detectChanges();
const checkboxes = fixture.nativeElement.querySelectorAll(
'input[type="checkbox"]',
);
checkboxes[1].dispatchEvent(new Event('change'));
fixture.detectChanges();
const component = fixture.componentInstance;
const tasks = component'tasks';
expect(tasks[0].done).toBe(false);
expect(tasks[1].done).toBe(true);
});
});
And here's one for the exhaustive @switch example — since the compiler already guarantees every case is handled, the test's job is just to confirm the right branch renders for each state.
import { TestBed } from '@angular/core/testing';
import { SessionBanner } from './session-banner';
describe('SessionBanner', () => {
it('renders the login button when logged out', () => {
const fixture = TestBed.createComponent(SessionBanner);
fixture.componentInstance['state'] = 'loggedOut';
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('button')).toBeTruthy();
});
it('renders the welcome message when logged in', () => {
const fixture = TestBed.createComponent(SessionBanner);
fixture.componentInstance['state'] = 'loggedIn';
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Welcome back');
});
});
Neither test is exotic — that's kind of the point. These template features are designed to remove boilerplate and catch mistakes earlier, not to introduce new things you need special tooling to verify.
Bonus tips
-
Turn
strictTemplatesback on deliberately after upgrading. The migration disables it to avoid breaking your build, but you're giving up real bug-catching by leaving it off — budget time to fix what it surfaces rather than leaving the migration's default in place indefinitely. -
Use
never(expression)instead of bare@default neverfor nested discriminants. If you switch on a property rather than the whole union, barenevercan't tell the compiler what to check for exhaustiveness — you need the explicit expression form. - Don't overuse inline arrow functions for anything beyond a simple predicate. They're great for a one-line comparison like the toggle example above; if the logic grows past a single expression, move it into a component method and call that instead.
-
Watch for the
$safeNavigationMigration()wrapper after upgrading. It's automatically added to preserve old nullish-coalescing behavior, but it's worth going through those spots by hand afterward — you can often remove it once you confirm the newundefinedsemantics don't change your logic. -
Spread syntax is great for merging, not for hiding complexity. If a
[class]binding needs to merge three or four different sources, that's usually a sign to pull the merged object into acomputed()signal instead of stacking spreads in the template.
Recap
None of Angular 22's template changes are individually dramatic, but together they close a real gap between what you can express in a component's TypeScript and what you can express directly in its template. Comments make intent visible where the binding actually lives, spread syntax removes a category of throwaway helper properties, arrow functions cut down on boilerplate in loops, and the stricter compiler — especially exhaustive @switch checks — catches a class of bugs that used to only show up after someone clicked around long enough to find them.
If you're on an older Angular version, this is a good release to actually read the migration output for, rather than just running ng update and moving on — a few of these changes are quiet enough that they're easy to miss until something in your template stops compiling.
What did you think?
Did this approach match how you are solving it, or do you have a different take? Drop a comment — I genuinely read every single one.
Found this helpful?
If this saved you even a few minutes of debugging or confusion, hit that clap button so others can find it too. It really does make a difference.
Want more tips like this?
I share one practical dev insight every week. Follow me here on Medium or subscribe to my newsletter so you never miss one.
Let us connect — find me on LinkedIn or GitHub and let us keep the conversation going.
Follow Me for More Angular & Frontend Goodness:
I regularly share hands-on tutorials, clean code tips, scalable frontend architecture, and real-world problem-solving guides.
- 💼 LinkedIn — Let’s connect professionally
- 🎥 Threads — Short-form frontend insights
- 🐦 X (Twitter) — Developer banter + code snippets
- 👥 BlueSky — Stay up to date on frontend trends
- 🌟 GitHub Projects — Explore code in action
- 🌐 Website — Everything in one place
- 📚 Medium Blog — Long-form content and deep-dives
- 💬 Dev Blog — Free Long-form content and deep-dives
- ✉️ Substack — Weekly frontend stories & curated resources
- 🧩 Portfolio — Projects, talks, and recognitions
- ✍️ Hashnode — Developer blog posts & tech discussions
- ✍️ Reddit — Developer blog posts & tech discussions
Top comments (0)