# Interpolation: how `{0}`, `{1}` placeholders work
Salesforce Custom Labels use **positional interpolation**: `{0}`, `{1}`, `{N}` placeholders that get replaced with runtime values at render time. This is the same substitution syntax used by Apex `String.format` and LWC `@salesforce/label`, so one label works across all three frameworks.
---
## TLDR
It's a string-replace. i18next finds every `{0}`, `{1}`, `{N}` in the cached label content and substitutes the value at that key from the second argument of `t()`.
---
## Basic example
**Label:**
```xml
GreetingHello, {0}
```
**Component:**
```typescript
const { t } = useTranslation("c");
const userName = "Tosin";
return
{t("Greeting", { 0: userName })}
;
// → "Hello, Tosin"
```
---
## How it works
1. At boot, the Platform SDK's `SalesforceBackend` fetches the label content from the org via GraphQL
2. i18next caches it: `{ "c:Greeting": "Hello, {0}" }`
3. At render time, the component calls `t("Greeting", { 0: "Tosin" })`
4. i18next's interpolator does:
```javascript
template = "Hello, {0}";
template.replace("{0}", "Tosin");
// → "Hello, Tosin"
```
5. The resulting string renders to the DOM
The configuration that makes this work is in `src/i18n/index.ts`:
```typescript
interpolation: {
escapeValue: false,
prefix: "{", // scan for {X} placeholders
suffix: "}",
},
```
i18next ships with `{{name}}`-style named interpolation by default. We override it to use Salesforce's `{0}` positional style so one label works across Apex, LWC, and React.
---
## Multiple placeholders
**Label:**
```xml
Failed to save {0}: {1}
```
**Component:**
```typescript
t("Save_Failed", { 0: "Account", 1: "Permission denied" });
// → "Failed to save Account: Permission denied"
```
Order in the object doesn't matter; the placeholder **number** is what binds:
```typescript
t("Save_Failed", { 1: "Permission denied", 0: "Account" }); // same result
```
---
## Same placeholder used twice
Salesforce labels sometimes reuse the same positional placeholder:
**Label:**
```xml
User {0} cannot edit {0}'s own profile
```
**Component:**
```typescript
t("Profile_Edit_Error", { 0: "tosin" });
// → "User tosin cannot edit tosin's own profile"
```
This is common when sentence structure differs across languages and the same value appears in different grammatical roles.
---
## Numeric and date values
Placeholders accept any JavaScript value. The value's `toString()` is what gets interpolated:
```typescript
// Numbers
t("Record_Count", { 0: 42, 1: 100 });
// label: "Showing {0} of {1} records" → "Showing 42 of 100 records"
// Dates (default JS toString, not localized)
t("Last_Modified", { 0: new Date() });
// label: "Last modified {0}" → "Last modified Mon Jul 07 2026 14:23:00 GMT-0700"
```
**Note:** This does **not** apply locale-aware formatting (e.g., `1.000,00` for German number formatting). For that, you'd use `Intl.NumberFormat` / `Intl.DateTimeFormat` separately and pass the **formatted string** as the placeholder value:
```typescript
const formattedCount = new Intl.NumberFormat(locale).format(count);
t("Record_Count", { 0: formattedCount, 1: total });
```
The Platform SDK's `fetchI18nContext()` gives you `ctx.locale` and `ctx.currency` for feeding `Intl` formatters.
---
## Translations must preserve placeholders
**English:**
```xml
Failed to save {0}: {1}
```
**Spanish translation:**
```xml
```
The placeholders stay as `{0}`, `{1}`; they can move (grammar might flip the order), but the **numbers must match**. If the Spanish translation says `{A}` or drops `{1}`, the substitution breaks.
This is why Translation Workbench is valuable: it shows translators the placeholders and warns if they're missing.
---
## Failure modes
### Missing placeholder value
**Label:** `"Hello, {0} from {1}"`
**Call:**
```typescript
t("Greeting", { 0: "Tosin" }); // forgot {1}
// → "Hello, Tosin from {1}" (literal placeholder leaks)
```
There's no runtime error or console warning; the unsubstituted placeholder just renders as-is. This is a visual bug, not a crash.
**Fix:** Always pass every placeholder the label expects. PR review catches this today (no build-time validation yet).
### Missing label entirely
```typescript
t("Nonexistent_Key");
// → "Nonexistent_Key" (literal key string)
```
This is the **unregistered manifest key** trap (see [gotchas.md](gotchas.md)): the label wasn't fetched, so i18next has nothing to interpolate.
---
## Worked examples by complexity
### 1. No interpolation (most labels)
**Label:** `"Welcome"`
**Call:** `t("Welcome_Text")`
**Result:** `"Welcome"`
Most labels are just lookups, no placeholders.
---
### 2. Single value
**Label:** `"You have {0} items"`
**Call:** `t("Item_Count", { 0: 5 })`
**Result:** `"You have 5 items"`
---
### 3. Error with context
**Label:** `"{0} failed at step {1} for record {2}: {3}"`
**Call:**
```typescript
t("Pipeline_Error", {
0: "OnboardingPipeline",
1: err.step,
2: record.name,
3: err.message,
});
```
**Result:** `"OnboardingPipeline failed at step ValidateAccount for record Acme Corp: Missing tax ID"`
One label, reusable across pipelines. Translated once. Engineers in any framework call it the same way.
---
### 4. Pluralization (leveraging i18next on top of positional)
i18next has native pluralization support. For Salesforce labels, you author separate labels for each plural form:
**Labels:**
```xml
Item_Count_oneYou have {0} itemItem_Count_otherYou have {0} items
```
**Manifest:**
```typescript
export const labelManifest = [
"c:Item_Count_one",
"c:Item_Count_other",
];
```
**Component:**
```typescript
t("Item_Count", { 0: count, count: count });
// count = 1 → "You have 1 item"
// count = 5 → "You have 5 items"
```
i18next reads the `count` arg and picks the `_one` or `_other` suffix per the English plural rules. The `0` arg drives substitution. The suffixes must be **lowercase** (`_one`, `_other`): i18next derives them from the browser's `Intl.PluralRules` categories, which are lowercase, and appends them verbatim to the key. A PascalCase `_One` label would never be found, and the label would render as its literal key name.
Languages with more plural forms (Russian, Polish, Arabic) need more label variants, `_zero`, `_few`, `_many`, etc. Author them as separate Custom Labels with the appropriate lowercase suffixes.
---
## Why positional (`{0}`) instead of named (`{name}`)?
**Portability.** Salesforce has three UI frameworks:
- Apex (backend): `String.format(label, [arg0, arg1])`
- LWC: `@salesforce/label/c.Save_Failed` with `{0}/{1}`
- React UI Bundles: `t("Save_Failed", { 0: ..., 1: ... })`
All three use the same Custom Labels metadata. If React used `{name}` syntax, the same logical string would need to be authored **twice**, once for Apex/LWC with `{0}`, once for React with `{name}`. Translators would maintain two formats. Engineers couldn't reuse strings across frameworks.
With the bridge (`prefix: "{", suffix: "}"`), **one Custom Label serves all three**. Portability is the win.
---
## What the bridge doesn't do (not in MVP)
- **Date/number formatting**: `{ 0: new Date() }` produces JS's default `toString`, not localized formatting. Wiring up `Intl.DateTimeFormat` / `Intl.NumberFormat` via i18next's `interpolation.format` callback is a separate design decision (not in the MVP scope).
- **Type safety**: nothing checks that `Save_Failed` actually has 2 placeholders at build time. A build-time extractor could close this gap (future work).
- **Escape for literal `{0}` in content**: i18next has escape options if labels genuinely need to contain literal curly braces. Edge case.
---
## Visual flow
```text
┌─────────────────────────────────────────────────┐
│ 1. Custom Label (authored in Salesforce): │
│ "Failed to save {0}: {1}" │
└─────────────────────────────────────────────────┘
↓
GraphQL fetch at boot
↓
┌─────────────────────────────────────────────────┐
│ 2. Cached in i18next memory: │
│ { 'c:Save_Failed': "Failed to save {0}: {1}" }│
└─────────────────────────────────────────────────┘
↓
Component calls t('Save_Failed',
{ 0: 'Account',
1: 'Permission denied' })
↓
┌─────────────────────────────────────────────────┐
│ 3. i18next's interpolator does: │
│ template = "Failed to save {0}: {1}" │
│ template.replace('{0}', 'Account') │
│ .replace('{1}', 'Permission denied') │
└─────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ 4. Output: │
│ "Failed to save Account: Permission denied" │
└─────────────────────────────────────────────────┘
```
---
## Related
- [i18n-setup.md](i18n-setup.md): the init file where the `prefix`/`suffix` are configured
- [label-xml.md](label-xml.md): how to author labels with placeholders
- [verifying.md](verifying.md): testing interpolated labels
- [gotchas.md](gotchas.md): silent-fail traps