---
title: 'Template and Expression Syntax'
source: 'https://academia.sh/en/courses/component-based-development/template-and-expression-syntax'
course: 'Component-Based Interface Development'
language: en
updated: '2026-08-17T18:11:04+00:00'
license: 'CC BY-SA 4.0'
---

# Template and Expression Syntax

Binding markup to data; compiling a template once and running it many times, separating static and dynamic parts, the scope and limits of expressions, text escaping, and the distinction between attribute and property binding.

The previous lesson's components built the view description with nested function
calls. When a measurement table's entire structure is written this way, the shape of
the markup is lost: where each element closes, and which attribute belongs to which
element, do not become readable among the call parentheses.

This lesson covers writing markup in its own syntax and binding it to data. The
mechanism to be built is called a **template**: a markup text that carries
expressions, and a compilation step that turns it into a function producing a view
description.

## Two Writing Families

There are two ways to bring markup into a program, and framework families split along
this line.

In the first way, **markup is embedded into the language**: the programming
language's syntax is extended, and markup becomes an expression of the language.
There is no separate concept of an expression slot; everything inside the markup is
already an expression of the language. The gain is that the full range of the
language's capabilities can be used directly.

In the second way, **the template is a separate text**: markers specific to markup are
added, and this text is compiled into a function. The template language is smaller
than the language; it allows only expressions and a handful of directives. The gain is
that the small language is analyzable: the compiler knows which part is static and
which is dynamic, and it can act on that knowledge.

Both ways arrive at the same place: a function that produces a view description from
state. The difference is when this function is produced, and with how much
information.

## Compiling the Template

The compiler below is a small example of the second way. The template is parsed once,
and the result turns into a command tree; only this tree runs on every render.

```js
// template-compiler.mjs — compiles template text into a function that produces a view
const TEMPLATE = `
<div class="badge" :state="value >= threshold ? 'exceeded' : 'normal'" @click="open">
  <span class="name">{{name}}</span>
  <span class="value">{{value}} {{unit}}</span>
</div>`.trim();

// 1) Lexical analysis: tags and text chunks.
// The tag pattern skips the ">" character inside quotes.
const TAG = /<\/[a-z][\w-]*>|<[a-z][\w-]*(?:\s+[:@]?[\w-]+(?:="[^"]*")?)*\s*>|[^<]+/g;
const tokenize = (text) => text.match(TAG) ?? [];

// 2) Splits text into static slices and expressions: "{{name}}: {{value}}" → 4 parts.
function splitText(text) {
  const parts = [];
  let last = 0;
  for (const match of text.matchAll(/\{\{([^}]+)\}\}/g)) {
    if (match.index > last)
      parts.push({ kind: "static", value: text.slice(last, match.index) });
    parts.push({ kind: "expression", source: match[1].trim() });
    last = match.index + match[0].length;
  }
  if (last < text.length) parts.push({ kind: "static", value: text.slice(last) });
  return parts;
}

// 3) Splits attributes into three: static, bound (:), and event (@).
function splitAttributes(tag) {
  const staticAttrs = {}, bound = {}, event = {};
  for (const [, name, value] of tag.matchAll(/([:@]?[\w-]+)="([^"]*)"/g)) {
    if (name.startsWith(":")) bound[name.slice(1)] = value;
    else if (name.startsWith("@")) event[name.slice(1)] = value;
    else staticAttrs[name] = value;
  }
  return { static: staticAttrs, bound, event };
}

// 4) Compilation: the template is parsed once, the result turns into a command tree.
function compile(template, scopeNames) {
  const evaluate = (source) =>
    new Function(...scopeNames, `return (${source});`);
  const stack = [{ children: [] }];
  let staticCount = 0, dynamicCount = 0;

  for (const chunk of tokenize(template)) {
    if (chunk.startsWith("</")) { stack.pop(); continue; }
    if (chunk.startsWith("<")) {
      const name = chunk.match(/^<([a-z]+)/)[1];
      const { static: staticAttrs, bound, event } = splitAttributes(chunk);
      staticCount += Object.keys(staticAttrs).length;
      dynamicCount += Object.keys(bound).length + Object.keys(event).length;
      const node = {
        name, static: staticAttrs, children: [],
        bound: Object.fromEntries(Object.entries(bound)
          .map(([k, v]) => [k, evaluate(v)])),
        event: Object.fromEntries(Object.entries(event)
          .map(([k, v]) => [k, evaluate(v)])),
      };
      stack.at(-1).children.push(node);
      stack.push(node);
      continue;
    }
    if (!chunk.trim()) continue;
    for (const p of splitText(chunk.trim())) {
      if (p.kind === "static") { staticCount++; stack.at(-1).children.push(p.value); }
      else { dynamicCount++; stack.at(-1).children.push(evaluate(p.source)); }
    }
  }
  console.log(`compile: ${staticCount} static parts, ${dynamicCount} dynamic parts`);
  return { root: stack[0].children[0], scopeNames };
}

// 5) Execution: command tree + scope → view description.
function produce({ root, scopeNames }, scope) {
  const args = scopeNames.map((name) => scope[name]);
  const out = (d) => {
    if (typeof d === "string") return d;
    if (typeof d === "function") return String(d(...args));
    return {
      name: d.name,
      attrs: { ...d.static,
        ...Object.fromEntries(Object.entries(d.bound)
          .map(([k, f]) => [k, f(...args)])),
        ...Object.fromEntries(Object.entries(d.event)
          .map(([k, f]) => [`on:${k}`, f(...args).name || "function"])) },
      children: d.children.map(out),
    };
  };
  return out(root);
}

const SCOPE = ["name", "value", "unit", "threshold", "open"];
const compiled = compile(TEMPLATE, SCOPE);

const write = (d, indent = 0) => typeof d === "string"
  ? `${" ".repeat(indent)}${JSON.stringify(d)}`
  : [`${" ".repeat(indent)}<${d.name}${Object.entries(d.attrs)
      .map(([k, v]) => ` ${k}=${JSON.stringify(v)}`).join("")}>`,
     ...d.children.map((c) => write(c, indent + 2))].join("\n");

for (const measurement of [
  { name: "Temperature", value: -4.2, unit: "°C", threshold: 30, open: () => {} },
  { name: "Relative humidity", value: 94, unit: "%", threshold: 90, open: () => {} },
]) console.log(`\n${write(produce(compiled, measurement))}`);

// An expression sees only the declared scope names.
try {
  new Function(...SCOPE, "return (exceededCount);")("x", 0, "", 0, null);
} catch (error) {
  console.log(`\nname not in scope: ${error.constructor.name} — ${error.message}`);
}
```

```
compile: 4 static parts, 5 dynamic parts

<div class="badge" state="normal" on:click="open">
  <span class="name">
    "Temperature"
  <span class="value">
    "-4.2"
    " "
    "°C"

<div class="badge" state="exceeded" on:click="open">
  <span class="name">
    "Relative humidity"
  <span class="value">
    "94"
    " "
    "%"

name not in scope: ReferenceError — exceededCount is not defined
```

The output has four results.

**Compile once, run many times.** The parsing line was written only once; the template
text was never looked at again while producing the view for two different
measurements. The same holds for fifteen badges. Parsing the template is not part of
the render cost.

**Static and dynamic parts are separated.** The compiler knows that four parts depend
on no state at all. This knowledge is used in the next lesson to reduce cost: the parts
that never need comparing are known in advance.

**An expression is evaluated in the declared scope.** The name `value` in the
template is the `value` variable in the component's scope; a name absent from the
scope produces an error at runtime. The names the template sees are limited to the
names the component supplies.

**The three binding types are written with separate markers.** An attribute without a
prefix is static; the colon prefix takes its value from an expression; the at-sign
prefix binds an event. Having the distinction visible in the syntax lets the compiler
treat each one differently.

## The Limits of Expressions

Template expressions run during rendering, and on every render. Two rules follow from
this.

**An expression must not produce a side effect.** The previous lesson's purity
condition applies to the template as well: an expression that changes state, starts a
request, or increments a counter behaves depending on the render count.

**An expression must be cheap.** Sorting or filtering a list inside the template is
recomputed on every render. Doing that computation once and caching its result is the
subject of the course's derived-values lesson.

Keeping the template language limited to expressions supports these rules at the
syntax level too: building a loop, performing an assignment, or catching an error is
impossible in a language where statements cannot be written. That work belongs to the
component's body.

## Text, Attribute, and Event Binding

The behavior of the three binding types differs, and confusing them produces two
familiar problems.

```js
// binding-types.mjs — text escaping and the difference between attribute/property binding
// 1) Text interpolation escapes; raw insertion does not.
const escape = (s) => String(s)
  .replace(/&/g, "&amp;").replace(/</g, "&lt;")
  .replace(/>/g, "&gt;").replace(/"/g, "&quot;");

const note = 'Sensor <b>suspicious</b> reading';       // free text entered into a form
console.log(`text interpolation : <p>${escape(note)}</p>`);
console.log(`raw insertion      : <p>${note}</p>`);

const attrValue = 'snow" data-access="full';           // value carrying a quote
console.log(`\nattribute (escaped)  : <li type="${escape(attrValue)}">`);
console.log(`attribute (unescaped): <li type="${attrValue}">`);
const attributeCount = (s) => [...s.matchAll(/[\w-]+="/g)].length;
console.log(`attributes produced — escaped: ` +
  `${attributeCount(`<li type="${escape(attrValue)}">`)}, ` +
  `unescaped: ${attributeCount(`<li type="${attrValue}">`)}`);

// 2) An attribute is the document's string; a property is the live value; they diverge once the user types.
function makeField() {
  const node = { attrs: {}, liveValue: "", userTouched: false };
  return {
    node,
    writeAttribute(value) {
      node.attrs.value = String(value);
      if (!node.userTouched) node.liveValue = String(value); // initial value
    },
    writeProperty(value) { node.liveValue = String(value); },
    userTypes(text) { node.userTouched = true; node.liveValue = text; },
  };
}

for (const type of ["attribute", "property"]) {
  const field = makeField();
  const write = (v) => type === "attribute" ? field.writeAttribute(v) : field.writeProperty(v);
  write("snow");                        // initial render: search text from state
  field.userTypes("snow de");           // user is typing
  write("snow depth");                  // state updated, re-render
  console.log(`\n${type} binding → value in document: ` +
    `${JSON.stringify(field.node.attrs.value ?? null)}, ` +
    `visible in field: ${JSON.stringify(field.node.liveValue)}`);
}
```

```
text interpolation : <p>Sensor &lt;b&gt;suspicious&lt;/b&gt; reading</p>
raw insertion      : <p>Sensor <b>suspicious</b> reading</p>

attribute (escaped)  : <li type="snow&quot; data-access=&quot;full">
attribute (unescaped): <li type="snow" data-access="full">
attributes produced — escaped: 1, unescaped: 2

attribute binding → value in document: "snow depth", visible in field: "snow de"

property binding → value in document: null, visible in field: "snow depth"
```

**Text interpolation escapes.** Text the user enters stays text; the markup
characters inside it are converted to the character references from the Web
Fundamentals and HTML course. This is the template syntax's default behavior, and it
is a deliberate security decision. The **raw insertion** path the same template
language offers turns this protection off: the value is interpreted as markup. Raw
insertion's input should never come from the user.

**Attribute binding also escapes.** An unescaped value closes the quote and opens a
new attribute: the example produced two attributes instead of one. The reason the
template compiler can choose the correct escaping is that it knows where it is placing
the value; the same value is escaped by different rules inside text, inside an
attribute, and as an address value.

**Attribute and property are not the same thing.** The distinction built in the DOM
API lesson turns into a choice in the template. Writing an attribute changes the
string in the document; once the user has touched the field, it no longer tracks the
field's live value. The output's last two lines show this: the attribute path updates
the value in the document, but the text visible in the field stays what the user
typed. The property path updates the live value.

The rule follows from this: **bindings that carry the displayed value are written as
a property; bindings that carry metadata in the document are written as an
attribute.** Boolean attributes are a separate case: they carry presence or absence in
the document, and true or false on the program side; writing a boolean attribute with
a false value is not the same thing as removing it.

**Event binding binds a function.** The marker `@click="open"` in the template does
not produce a string; it produces a reference to a function found in the scope, and
that reference is what shows up in the output as `on:click="open"`. Interpreting the
value as a string would mean writing code inside the markup.

## Summary

- A template is markup written in its own syntax and bound to data; the compilation
  step turns it into a function that produces a view description.
- Markup is either embedded into the language or compiled as a separate text; in the
  second way, the compiler can tell static and dynamic parts apart.
- The template is parsed once, and the produced command tree runs on every render;
  parsing is not part of the render cost.
- Template expressions are evaluated in the component's scope, must produce no side
  effect, and must be cheap; the inability to write statements supports this rule at
  the syntax level.
- Text interpolation and attribute binding escape by default; raw insertion does not,
  and its input should never come from the user.
- Bindings that carry the displayed value are written as a property, bindings that
  carry metadata in the document as an attribute; event binding binds a function
  reference, not a string.

## Next Step

This lesson's template described a single badge, and its structure stayed the same in
every state: the same elements, in the same order. A real dashboard, by contrast, has
a variable structure — when the filter matches no measurement, an empty-state message
appears in place of the table, the measurement list grows and shrinks, rows get
reordered. The next lesson adds conditional and iteration directives to the template
and asks the real question: when the list changes, what determines which row in the
new description corresponds to which node in the tree?
