skip to content

Expression Rules & Interpolation

What {{ }} interpolation and template expressions may contain: supported operators, ?. and !, template literals, no globals or new. Interviewers ask what ?. yields since v22.

part ofAngularoverview, primer and where to startread it →
on this pageshow

explore

questions

4

In an Angular template, what does {{ }} interpolation evaluate its expression against, and what text does it render for null, undefined or an object?

level: juniorimportance: must knowfreq 58%

answer

  1. this is implied
  2. template variables shadow members
  3. result becomes a string
  4. nullish renders as nothing
  5. signals must be called

basics

~20 s

Interpolation evaluates its expression against the component instance, with this implied, plus any template variables in scope, converts the result to a string and writes it as text. null and undefined render as an empty string; an object renders through String().

solid answer

~40 s

An expression inside `{{ }}` resolves bare names against the **component instance** (`this` is implied) and against **template variables** in scope - a `@let`, a `#ref` or a loop variable with the same name shadows the class member, and `this.name` reaches the member explicitly. Angular re-evaluates the expression each time the view is checked and updates the text node only when a value changed. The result is stringified: `null` and `undefined` become an empty string, strings are used as they are, and anything else goes through `String()`, so a plain object prints `[object Object]`. A signal must be called, `{{ count() }}`; writing `{{ count }}` prints the function (in development builds something like `[Signal: 3]`). Interpolation in an attribute value, `title="Hi {{ name() }}"`, compiles to a property binding of the concatenated string.

code

ts · 17 lines
ts
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-greeting',
  template: `
    <p>Hello, {{ nickname() }}</p>          <!-- '' while nickname is null -->
    <p>Hello, {{ nickname() ?? 'guest' }}</p>
    @let title = 'Draft';
    <h2>{{ title }} / {{ this.title }}</h2> <!-- 'Draft / Welcome' -->
    <p title="Signed in as {{ nickname() }}">{{ unread() }} unread</p>
  `,
})
export class Greeting {
  protected readonly title = 'Welcome';
  protected readonly nickname = signal<string | null>(null);
  protected readonly unread = signal(3);
}

go deeper

for a junior

Remember that names resolve on the component with this implied, that null and undefined print nothing, and that signals need parentheses.

for a middle

Explain the stringification table, shadowing by template variables and this., and how attribute interpolation becomes a property binding.

for a senior

Spot silent empty output from nullish values and costly expressions re-run on every check, and move that logic into computed() values.

for a principal

Set team conventions for display logic: what may live in a template expression and what must be derived in the class so templates stay cheap and testable.

## What interpolation is **Interpolation** is the `{{ expression }}` syntax in an Angular template. The compiler turns every interpolation into an instruction that evaluates the expression when the view is checked and writes the resulting string into a DOM text node (or, inside an attribute value, into a property). It is the simplest one-way binding Angular has: data flows from the component to the DOM, never back. ## What names resolve to An Angular template expression has no access to the global scope. Its **lexical context** is: - the **component instance** - every bare identifier such as `userName` or `greet(...)` is read as `this.userName` or `this.greet(...)`; `this` is implied; - **template variables** in scope - `@let` declarations, `#ref` references, and the variables a block such as `@for` introduces (`$index` and friends); - two allowed globals, `undefined` and the `$any()` cast. When a template variable and a class member share a name, the **variable wins** (it shadows the member). Writing `this.title` explicitly reaches the class member. This matters most with `@let`, which is often declared under the same name as a signal to narrow its type. ## How the value becomes text The runtime converts every interpolated value with a small helper: | Value | Rendered text | |---|---| | a string | the string itself | | `null` or `undefined` | `''` (nothing) | | a number or boolean | `String(value)`, e.g. `3`, `true` | | a plain object | `String(value)`, i.e. `[object Object]` | | an array | `String(value)`, i.e. comma-joined items | | an object with a custom `toString()` | whatever `toString()` returns | Two consequences come up in interviews: 1. **Nullish values disappear silently.** `Hello, {{ nickname }}` renders `Hello, ` when `nickname` is `null` - no error, no `null` text. That is convenient, but it also hides a missing value, so give an explicit fallback with `??` when the empty output would confuse the user. 2. **Objects are not formatted.** `{{ user }}` prints `[object Object]`; bind a property (`{{ user.name }}`) or transform the value (a pipe or a `computed()` in the class) instead. The value is written as **text**, not parsed as markup. ## When it re-evaluates Angular evaluates each interpolation every time its view is **checked**. Since v22 components default to `OnPush`, so a view is checked when something marks it dirty (a signal it reads changes, an input changes, a template event fires). During a check, Angular compares the new value with the previous one and touches the DOM only if it changed. Because the expression can run on every check, keep it cheap - heavy work belongs in a `computed()` in the class. ## Signals in interpolation A signal is a getter **function**. Reading its value requires calling it: - `{{ count() }}` renders the current value and registers the view as a consumer, so the view is refreshed when the signal changes. - `{{ count }}` interpolates the function itself. In development builds a signal's `toString()` prints `[Signal: 3]`; in production it is the function's source text. The **extended diagnostic** "Signals must be invoked in template interpolations" (`NG8109`) flags this at build time. ## Interpolation inside attributes `<img alt="Avatar of {{ userName() }}">` is not a string attribute any more: the compiler turns it into a **property binding** of the concatenated string (`alt` is set to `'Avatar of ' + value`). Binding a non-string value this way therefore stringifies it first, which is why `[prop]="value"` is the form to use when the target property expects a number, boolean or object. ## Common mistakes - Referring to `window`, `Math` or `JSON` - they are looked up on the component and are not found. - Interpolating a signal without calling it. - Expecting `null` to print as the word "null". - Doing expensive work (sorting, filtering) directly inside `{{ }}`.

  • Why can a template show '[object Object]' after a refactor that changed a string input to an object?
    Interpolation stringifies whatever the expression returns. A string prints as is, but a plain object goes through `String()`, which yields `[object Object]`. Bind the specific field, such as `{{ user().name }}`, or derive a display string in a `computed()`.
  • How do you reach a component field named title from a template that also declares @let title?
    Write `this.title`. Bare names resolve to template variables first, so the `@let` shadows the field; the explicit `this.` prefix skips template variables and reads the component instance.

saying these in an interview costs you the question

  • Interpolating null prints the word null
  • Template expressions can read window, Math or JSON
  • {{ count }} renders a signal's current value without calling it
  • Interpolation re-renders the text node on every check even when unchanged
  • A class member always wins over a template variable of the same name
open as a page

Which JavaScript constructs are not allowed in Angular template expressions, and why does {{ Math.round(price) }} fail?

level: middleimportance: must knowfreq 55%

basics

~20 s

Template expressions are a JavaScript subset: no globals except undefined and $any, no new, no bitwise operators, no destructuring, comma operator or declarations, and no assignments in bindings. Math.round fails because Math is looked up on the component, not on the global scope.

open as a page

In an Angular template, what is the difference between user?.name and user!.name, and what does each do when user is null?

level: middleimportance: should knowfreq 48%

basics

~20 s

user?.name is a runtime guard: if user is null or undefined it short-circuits and yields undefined (null before v22). user!.name is a TypeScript non-null assertion that only silences the type checker; at runtime a null user still throws a TypeError.

open as a page

After removing the $safeNavigationMigration() wrappers an Angular 22 update added, {{ greet(user()?.firstName) }} shows 'Hello, undefined' instead of 'Hello, guest'. Why?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Since Angular 22 the template ?. operator yields undefined like JavaScript, not null. greet() tests name === null, so undefined falls through to 'Hello, undefined'. The removed wrapper told the compiler to keep the old null result for that argument.

open as a page