skip to content

A reviewer rejects Angular code that builds a cart message from two $localize fragments around a count; why is that wrong, and how should it be marked?

level: seniorimportance: should knowfreq 28%

answer

  1. translators see pieces, not a sentence
  2. word order differs between languages
  3. one message per sentence
  4. named placeholders instead of PH
  5. plural agreement belongs in the message

basics

~20 s

Fragments become separate translation units, so translators cannot see the whole sentence, reorder words or agree the noun with the count. Mark one whole message with a named placeholder, and put count-dependent wording in an ICU plural.

solid answer

~40 s

Code like ``$localize`You have` + ' ' + n + ' ' + $localize`items in your cart` `` produces two unrelated translation units with no context, and the concatenation hardcodes English word order: a language that puts the verb last, or needs the count elsewhere, cannot be expressed. The fix is **one message per sentence** with the value as a placeholder: ``$localize`You have ${n}:itemCount: items in your cart` ``. The `:itemCount:` suffix names the placeholder so translators see `{$itemCount}` instead of `{$PH}` and can move it. In a template, mark the whole sentence with `i18n`, including inner elements, which become movable `START_`/`CLOSE_` placeholders, and name interpolations with `//i18n(ph="...")`. Because "items" must still agree with the number, the count-dependent text belongs in a template ICU plural.

code

ts · 29 lines
ts
import { Component, computed, input } from '@angular/core';

@Component({
  selector: 'app-cart-toast',
  template: `
    <p role="status" i18n="Toast after adding to cart">
      {itemCount(), plural,
        =1 {You have one item in your cart}
        other {You have {{ itemCount() }} items in your cart}
      }
    </p>
    <p i18n="Cart total line">
      Total: <strong>{{ total() //i18n(ph="total") }}</strong> including tax
    </p>
  `,
})
export class CartToast {
  readonly items = input.required<readonly string[]>();
  readonly total = input.required<string>();
  readonly itemCount = computed(() => this.items().length);

  // Wrong: two units, fixed English order, no agreement.
  // readonly bad = computed(() => $localize`You have` + ' ' + this.itemCount() + ' ' + $localize`items`);

  // Better in code: one message with a named placeholder.
  readonly ariaLabel = computed(
    () => $localize`:Cart button label:Cart item count: ${this.itemCount()}:itemCount:`,
  );
}

go deeper

for a junior

Recall the rule: mark whole sentences, never pieces, and put changing values inside the message as placeholders.

for a middle

Explain how $localize placeholders are named with :name: and how template interpolations and nested tags become movable placeholders.

for a senior

Spot fragmented messages in review, explain word order and agreement failures concretely, and choose between named placeholders and an ICU plural for the fix.

for a principal

Discuss lint or review guardrails that stop fragment concatenation early, since fixing it after translation doubles vendor cost and release churn.

## The defect A common first attempt at localizing a dynamic sentence looks like this: ```ts const msg = $localize`You have` + ' ' + count + ' ' + $localize`items in your cart`; ``` It passes review in English and fails in almost every other language. The same defect appears in templates as `<span i18n>You have</span> {{ count }} <span i18n>items</span>`. ## Why fragments break translation 1. **No sentence context.** The extractor produces two units, "You have" and "items in your cart". A translator sees them separately, possibly far apart in the file, and has to guess they form one sentence. 2. **Fixed word order.** The concatenation order lives in code. Japanese typically puts the count and noun before the verb; German may move the verb to the end in some constructions. A translator can reword each fragment but cannot move the number between them. 3. **No agreement.** The noun must agree with the number ("1 items" is already wrong in English). With fragments, no translation can see the number at all. 4. **Spacing and punctuation are hardcoded.** The `' '` joins assume a language that separates words with spaces, which Japanese and Chinese do not. 5. **Churn.** Rewording the sentence means editing and re-translating several units instead of one. ## The fix in code: one message, named placeholders ```ts const msg = $localize`:Toast after adding to cart:You have ${count}:itemCount: items in your cart`; ``` - The whole sentence is **one message**, so it gets one translation unit and one ID. - `${count}` becomes a **placeholder**. Without a name it is serialized as `{$PH}` (then `{$PH_1}`, and so on). The `:itemCount:` suffix, written directly after the expression, names it `{$itemCount}`, and the name is stripped from the rendered string. - A translator can move `{$itemCount}` anywhere in the target sentence, because `$localize` reorders the substituted values to match the translation. ## The fix in templates Mark the **sentence**, not its pieces. Inline elements and interpolations inside a marked element become placeholders that the translator can move: ```html <p i18n="Cart total line"> Total: <strong>{{ total() //i18n(ph="total") }}</strong> including tax </p> ``` | Source construct | Placeholder in the translation file | | --- | --- | | `<strong>` / `</strong>` | `START_BOLD_TEXT` / `CLOSE_BOLD_TEXT` | | `{{ total() }}` without a name | `INTERPOLATION` | | `{{ total() //i18n(ph="total") }}` | `total` | | `${count}` in `$localize` | `PH`, or the `:name:` you give it | | `@if` block inside marked text | `START_BLOCK_IF` / `CLOSE_BLOCK_IF` | ## Agreement: move count-dependent wording into an ICU Naming the placeholder fixes word order but not "1 items". The count-dependent wording belongs in an ICU plural inside marked template text, where each locale supplies its own forms: ```html <p i18n="Toast after adding to cart"> {itemCount(), plural, =1 {You have one item in your cart} other {You have {{ itemCount() }} items in your cart}} </p> ``` If the text must be produced in TypeScript, for example for a toast service, a common pattern is to render it from a small component template with the ICU rather than concatenating in code. ## What to look for in review - Any `+` or template-literal join between two marked strings. - `i18n` on sibling `<span>`s that read as one sentence. - A number or name interpolated between separately marked pieces. - Unnamed placeholders in long messages, where `{$PH_2}` tells the translator nothing. - Punctuation or spaces added outside the marked message. The principle interviewers want stated: **the unit of translation is the whole sentence**, and anything variable inside it is a placeholder the translator controls, not glue code.

  • In $localize, how do you write a colon right after an unnamed substitution without it being read as a placeholder name?
    Escape it with a backslash: ``$localize`${label}\: ${value}` ``. A colon directly after a substitution starts a placeholder name, so an unnamed substitution followed by a literal colon needs the escape. A named substitution such as `${label}:label:` followed by `:` needs no escape.
  • Does wrapping part of a marked sentence in <strong> split it into two messages?
    No. Inside an element marked with `i18n`, nested elements become placeholder pairs such as `START_BOLD_TEXT` and `CLOSE_BOLD_TEXT` within the same message. The translator sees one sentence and can move the bold span to wherever it belongs in their language.

saying these in an interview costs you the question

  • Concatenating translated fragments is fine if each is marked
  • Translators can reorder words across separate units
  • Unnamed placeholders like PH are clear enough
  • Nested tags in marked text must be marked separately
  • Naming the placeholder also fixes plural agreement