DEV Community

Cover image for Angular Templates Just Got More Powerful — Here's What Angular 22 Actually Changed - What Changes in NG 22 (Part 9)
Rajat
Rajat

Posted on

Angular Templates Just Got More Powerful — Here's What Angular 22 Actually Changed - What Changes in NG 22 (Part 9)

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 @for loops
  • What strictTemplates being on by default means for your existing codebase
  • How exhaustive @switch checks 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>
Enter fullscreen mode Exit fullscreen mode

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

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 @for loop, 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)),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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' });
}
Enter fullscreen mode Exit fullscreen mode
✘ [ERROR] NG1054: Input 'user' is bound to both 'user' and 'user2'. [plugin angular-compiler]
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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 strictTemplates back 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 never for nested discriminants. If you switch on a property rather than the whole union, bare never can'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 new undefined semantics 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 a computed() 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)