Question: Template formatter with plural blocks
Companies: Figma
Difficulty: Easy
Problem statement
Implement format_template(text: str, keys: list[str], values: list[str]) -> str. keys[i] maps to values[i]; all values are strings.
text can contain two kinds of expressions in braces:
-
Placeholder
{name}: replace it with the value ofname. Whitespace around the name is ignored. -
Plural block
{name, plural, one{...} other{...}}: the value ofnamemust be an integer. If it is1, use theonebranch; otherwise use theotherbranch. If there is noonebranch, useother. Inside the chosen branch, every#becomes the number. A branch may also contain placeholders, which are expanded too.
Rules:
- Text outside expressions is copied unchanged.
#outside a plural branch is a plain character. - An expression whose name is not in
keysis left in the output unchanged. - Raise an error for an unmatched
{or}, a block type other thanplural, a plural block with nootherbranch, or a plural value that is not an integer. -
0 <= len(text) <= 10^4.
Example 1
text = "{n, plural, one{# layer} other{# layers}} hidden"
keys = ["n"]
values = ["4"]
→ "4 layers hidden"
With values = ["1"] the result is "1 layer hidden".
Example 2
text = "{owner} shared {k, plural, one{# board with {team}} other{# boards with {team}}}"
keys = ["owner", "k", "team"]
values = ["Rosa", "1", "Design"]
→ "Rosa shared 1 board with Design"
Reconstructed from the reported question; details may differ from what interviewers ask.
Approach
Clarify first. Pin these down before writing code:
- Values are strings. A plural block needs its value to parse as an integer. If it does not, raise an error.
-
Plural rule. Use English rules:
1picksone, every other number (including0) picksother. Ifoneis missing, fall back toother.otheris required. -
What
#means. Inside a plural branch,#becomes the count. Outside a branch it is a plain character ("Tag #1"stays as is). -
Nesting. A branch can hold ordinary placeholders:
one{# frame in {page}}. Say whether plural blocks may nest inside branches too (the parser below allows it for free). - Unknown names. Leave the expression unchanged, so a missing translation key is visible. Raising is also fine; agree on one.
- Malformed input. An unmatched brace or an unknown block type is an error, not silent output.
Why not regex. re.sub(r"\{(\w+)\}", ...) handles part 1 of this problem. It breaks on part 2, because a plural block contains nested braces and a regex cannot match balanced braces. Interviewers expect you to see that and switch to a small parser.
Scan once, match braces by depth. Walk the string left to right and copy plain characters to the output. At a {, find its closing } by counting depth (+1 on {, -1 on }). The text between them is one expression:
- No comma: it is a placeholder. Look up the trimmed name.
- Otherwise split on the first two commas into
name,kind,rest.kindmust beplural. Parserestas a list ofselector{text}pairs, again matching braces by depth. - Pick the branch, then render it recursively with the count bound. That one recursive call handles
#, nested placeholders and nested plural blocks.
Invariant. When _render sees a {, every brace before it is balanced and already consumed. So _match_brace always finds the closer of exactly this expression, and each character is copied or examined a bounded number of times per nesting level.
Code
from __future__ import annotations
class TemplateError(ValueError):
pass
def format_template(text: str, keys: list[str], values: list[str]) -> str:
"""Expand {name} placeholders and {name, plural, one{...} other{...}} blocks."""
if len(keys) != len(values):
raise TemplateError("keys and values must have the same length")
env = dict(zip(keys, values))
return _render(text, env, count=None)
def _render(text: str, env: dict[str, str], count: str | None) -> str:
out: list[str] = []
i, n = 0, len(text)
while i < n:
ch = text[i]
if ch == "#" and count is not None: # '#' only means something inside a branch
out.append(count)
i += 1
elif ch == "{":
end = _match_brace(text, i)
out.append(_expand(text[i + 1:end], env))
i = end + 1
elif ch == "}":
raise TemplateError(f"unmatched '}}' at index {i}")
else:
out.append(ch)
i += 1
return "".join(out)
def _match_brace(text: str, start: int) -> int:
"""Index of the '}' that closes the '{' at `start`."""
depth = 0
for j in range(start, len(text)):
if text[j] == "{":
depth += 1
elif text[j] == "}":
depth -= 1
if depth == 0:
return j
raise TemplateError(f"unmatched '{{' at index {start}")
def _expand(body: str, env: dict[str, str]) -> str:
"""`body` is the text between one outer pair of braces."""
if "," not in body: # simple placeholder
name = body.strip()
return env[name] if name in env else "{" + body + "}"
name, kind, rest = (part.strip() for part in body.split(",", 2))
if kind != "plural":
raise TemplateError(f"unknown block type {kind!r}")
branches = _parse_branches(rest)
if "other" not in branches:
raise TemplateError("plural block needs an 'other' branch")
if name not in env:
return "{" + body + "}"
raw = env[name]
try:
n = int(raw)
except ValueError:
raise TemplateError(f"value of {name!r} is not an integer: {raw!r}")
selector = "one" if n == 1 else "other"
return _render(branches.get(selector, branches["other"]), env, count=raw)
def _parse_branches(rest: str) -> dict[str, str]:
"""Parse `one{...} other{...}` into {"one": "...", "other": "..."}."""
branches: dict[str, str] = {}
i, n = 0, len(rest)
while i < n:
if rest[i].isspace():
i += 1
continue
j = i
while j < n and rest[j] != "{" and not rest[j].isspace():
j += 1
selector = rest[i:j]
while j < n and rest[j].isspace():
j += 1
if not selector or j >= n or rest[j] != "{":
raise TemplateError(f"bad plural branch near {rest[i:]!r}")
end = _match_brace(rest, j)
branches[selector] = rest[j + 1:end]
i = end + 1
return branches
Complexity
-
Time:
O(n * d)for a template of lengthnwith brace nesting depthd. Each_match_bracescan covers only the expression it closes, and a character is rescanned once per enclosing level. Real templates haved <= 3, so this is linear in practice. Output size addsO(n + total substituted length). -
Space:
O(n + k)for the output and thekkey/value pairs, plusO(d)recursion depth. - A single-pass version that tokenizes once (a stack of open braces) gives strict
O(n); see Follow-ups.
Test cases
-
"Hi {name}!",name=Ada→"Hi Ada!" -
"{n, plural, one{# file} other{# files}} selected"withn=1→"1 file selected" - Same template,
n=0→"0 files selected";n=12→"12 files selected" -
"{u} has {c, plural, one{# frame in {p}} other{# frames in {p}}}",u=Lin, c=3, p=Home→"Lin has 3 frames in Home"(placeholder inside a branch) -
"Tag #1: {x}",x=ok→"Tag #1: ok"(#outside a branch is literal) -
"{ name }",name=Bo→"Bo"(whitespace trimmed) -
"{n, plural, other{# items}}",n=1→"1 items"(noonebranch, falls back toother) -
"{missing} stays", no keys →"{missing} stays" -
""→"" - Errors:
"{oops","oops}","{n, select, a{x} other{y}}","{n, plural, one{x}}"(noother), and a plural value of"many"all raiseTemplateError.
Follow-ups
-
Exact matches like
=0{No files}. Check"=" + str(n)in the branches before the plural category. This is one extra dictionary lookup in_expand. -
Other languages. Plural categories differ (
zero,two,few,manyin Arabic or Polish). Replace"one" if n == 1 else "other"with aplural_category(locale, n)function backed by CLDR rules. The parser does not change. -
selectblocks.{gender, select, female{...} male{...} other{...}}picks a branch by the string value. Add akind == "select"case that uses the value itself as the selector, withotheras fallback and no#binding. -
Escaping. Support
'{'(ICU style) or\{for a literal brace. Handle it in the scanner so the brace never reaches depth counting. -
Render many times. Parse the template once into a tree (
Text,Var,Plural(name, branches),Hash), cache it by template string, and render the tree per call. This also gives strictO(n)parsing and lets you report every error with its position up front.
Interviewer expectations
Mid-level (L4)
- Solves part 1 quickly, then notices nested braces in part 2 and moves off regex.
- Gets
0→other,1→one, and#replacement right. - Writes a few tests, including a missing key.
Senior (L5)
- Asks about missing keys, non-integer counts and malformed input before coding.
- Uses one recursive render so branches with placeholders work without special cases.
- Keeps
#literal outside plural branches, and gives clear errors for unmatched braces.
Staff (L6+)
- Separates parsing from rendering and explains caching the parsed tree.
- Knows the real format (ICU MessageFormat): locale plural categories,
=Nmatches,select, escaping. - Discusses where this runs in a product (UI strings, translators editing templates) and why failing loudly on bad templates beats rendering broken text.