The template expression language has come a long way towards TypeScript in recent versions. Besides the local variables @let, you now have template literals, regex, spread, arrow functions, instanceof and even comments inside tags. The goal: less “glue” code in the class for simple things. The basics underneath — {{ }}, [ ], ( ) and [( )] — are on Data binding.
| Feature | Version | Example |
|---|---|---|
@let | v18.1 (stable v19) | @let total = price() * qty(); |
| Template literals | v20 | {{ `Hi ${name()}` }} |
**, in, void, typeof | v19–v20 | 'x' in obj |
| Regular expressions | /^\d+$/.test(v) | |
| Spread (array, object, arguments) | [...a, ...b] | |
| Arrow functions | items.filter(i => i.ok) | |
instanceof | err instanceof HttpErrorResponse | |
| Comments inside tags | <div // note | |
?. returns undefined | user?.name |
@let: local variables in templates
@let declares a read-only variable, visible in the current block and in its child blocks. It is recalculated automatically on every render. It is perfect for avoiding repetition of a long expression or an async pipe used several times.
<!-- @let declares a local, read-only variable, visible in the current block and in its children -->
@let user = user$ | async;
@let fullName = user?.firstName + ' ' + user?.lastName;
@let isAdmin = user?.roles?.includes('admin') ?? false;
<h1>{{ fullName }}</h1>
@if (isAdmin) { <app-admin-tools /> }
<!-- ideal for long expressions used several times -->
@let address = order().customer.billing.address;
<p>{{ address.street }}, {{ address.city }}, {{ address.zip }}</p>
<!-- and inside a @for, per item -->
@for (p of products(); track p.id) {
@let finalPrice = p.discount ? p.price * (1 - p.discount) : p.price;
<span>{{ finalPrice | currency: 'RON' }}</span>
}4 of 5 products · total 2900 RON
- Mouse150 RON
- Keyboard-20%360 RON
- Headphones900 RON
- Monitor-10%1260 RON
The new expressions, briefly
<!-- Template literals (v20) -->
<p>{{ `Hello, ${user().name}! You have ${count()} messages.` }}</p>
<img [alt]="`${user().name}'s avatar`" />
<!-- Operators: ** (power), in, typeof, void (v20) -->
{{ 2 ** 10 }}
@if ('discount' in product) { ... }
@if (typeof value === 'string') { ... }
<button (click)="void save()">Save</button>
<!-- Regular expressions (v21.0) -->
@let valid = /^07[0-9]{8}$/.test(phone());
<!-- Spread in arrays, objects and arguments (v21.1) -->
@let all = [...defaults, ...extra()];
<app-chart [options]="{ ...baseOptions, color: accent() }" />
{{ max(...values()) }}
<!-- Arrow functions (v21.2) -->
@let done = tasks().filter((t) => t.done);
<app-list [sortFn]="(a, b) => a.name.localeCompare(b.name)" />
<!-- instanceof (v21.2) — the class must be exposed from the component -->
@if (error() instanceof HttpErrorResponse) { <p>HTTP error {{ error().status }}</p> }instanceof requires the class to be exposed
The template sees only the component’s members (not global variables or imports), so a class used with instanceof must be put in a field.
import { HttpErrorResponse } from '@angular/common/http';
@Component({
template: `
@if (error() instanceof HttpErrorResponse) {
<p>The server responded with {{ error().status }}</p>
} @else if (error()) {
<p>Unexpected error</p>
}
`,
})
export class ErrorView {
readonly error = input<unknown>();
// the template only sees the component’s members, so the class must be exposed:
protected readonly HttpErrorResponse = HttpErrorResponse;
}Comments inside tags v22
<!-- v22: comments inside tags -->
<button
// TODO: replace with the tooltip directive
class="btn"
/* temporarily disabled:
[disabled]="saving()" */
(click)="save()"
>
Save
</button>Important change in v22: ?. returns undefined
To align with TypeScript, optional chaining in templates now returns undefined (not null). The ng update migration temporarily disables the nullishCoalescingNotNullable and optionalChainNotNullable diagnostics so your build doesn’t fail; re-enable them after you check the templates.
<!-- v22 (breaking): ?. in templates returns undefined, as in TypeScript (before: null) -->
{{ user()?.name }} <!-- user null → undefined -->
<!-- ✗ strict comparisons with null change -->
@if (user()?.name === null) { ... } <!-- now it is never true -->
<!-- ✓ compare with == null or use ?? -->
@if (user()?.name == null) { ... }
{{ user()?.name ?? 'Anonymous' }}From v22.2 the compiler lets the template read private members too. The style guide’s recommendation remains protected for everything the template uses: it makes clear that the member is for the view, not a public API.
How much logic do I put in the template?
// Arrow functions and methods in templates run on EVERY change detection of the component.
// For large lists or expensive calculations, move the logic into a computed():
// instead of: @let done = tasks().filter((t) => t.done);
readonly done = computed(() => this.tasks().filter((t) => t.done)); // memoised
// Rule: the template IS the place for simple, local expressions.
// Business logic, expensive sorts and anything reused → in the class (computed) or a pipe.- Short formatting, a template literal, a simple condition.
@letto name a sub-expression used several times.- Small filters on short lists, local to the view.
- Business rules (“can the user edit?”).
- Expensive calculations or large lists →
computed()(memoised). - Transformations reused across many components → a pipe.
- Anything you want to unit test.
Common pitfalls
@letcan’t be reassigned and isn’t visible before its declaration.- Global variables (
Math,JSON,Date, imported classes) aren’t visible in the template — expose them from the component. {{ { ...obj } }}: an object literal inside interpolation clashes with the braces; use it in a property binding or in@let.