2 Expressions
nillpoe edited this page 2026-09-29 08:18:49 +00:00

Expressions

A .kmx file is markup with Kotlin in it. This page defines exactly where Kotlin can appear, how the compiler finds where each piece of Kotlin ends, what types each position accepts, which names an expression can use, and what gets written to the output.

The rules describe KMX 0.1.1. The Generated Code page shows the Kotlin these rules produce.

Contents

  1. Where Kotlin appears
  2. How the compiler reads Kotlin
  3. Text expressions
  4. Attribute expressions
  5. Component attributes
  6. Markup literals inside Kotlin
  7. Directive expressions
  8. Names and scope
  9. Nullability and smart casts
  10. Evaluation order and control flow
  11. Whitespace around expressions
  12. Literal characters and escaping
  13. Errors

Where Kotlin appears

Position Syntax Must be Becomes
Header code any line before the markup that is not package, import, props or component Kotlin statements copied, in order, before the markup
Text expression {expr} between tags an expression of a text type insert(expr)
Attribute expression name={expr} on an element String?, Number? or Boolean? attribute("name", expr)
Interpolated attribute name="a{expr}b" String, Number, Char or Boolean per {} attribute("name", "a" + interpolate(expr) + "b")
Attribute spread {...expr} on an element Map<String, String?> attributes(expr)
Component attribute name={expr} on a component tag the parameter's type named argument name = expr
Directive head @if (expr), else if (expr), @for (header), @when (subject) what Kotlin's if, for, when accept if (expr), for (header), when (subject)
@when branch condition cond -> ... what a Kotlin when branch accepts cond -> { ... }
Statement block @{ statements } Kotlin statements copied at that position

Kotlin is copied as written. KMX only finds where each piece ends and replaces the markup literals inside it; kotlinc checks everything else.

How the compiler reads Kotlin

The compiler scans Kotlin without parsing it. While scanning, it skips over:

  • string literals, "..." and raw """...""", including a run of quotes that ends a raw string (""""),
  • string templates ${...}, scanned recursively as Kotlin,
  • character literals '...', with \ escapes,
  • line comments // ... up to the end of the line,
  • block comments /* ... */, which nest as in Kotlin,
  • balanced ( ), [ ] and { }.

A }, ) or ] inside any of these does not end the code. The code ends at:

Context End
{...} in text, ={...}, {...} in an interpolated value, {...spread}, @{...} the } that closes the opening brace
( ... ) after @if, else if, @for, @when the ) that closes the opening parenthesis
@when branch condition the first -> outside brackets
Header statement the end of the line, outside brackets and comments; a statement continues onto the next lines while a bracket or a block comment is open

Consequences:

  • {"}" + '}'} is one expression. So is {items.map { "<b>" }}.
  • A line comment runs to the end of the line, so {name // note} never closes. Put the } on the next line, or use /* note */.
  • A header statement that continues with a binary operator at the start of the next line, outside brackets, ends at the line break. Wrap it in parentheses or end the line with the operator.

Text expressions

{expr} in text writes the value of expr. The compiler emits insert(expr), and insert accepts only these types:

Static type Output
String the string as text, escaped; an empty string adds nothing
Number (Int, Long, Double, BigDecimal, ...) toString(), escaped: 1.0, 1000. Not locale-aware
Char the character
Boolean true or false
Node? (Element, Text, Comment, Fragment, Markup, or a markup literal) the node; a Fragment is flattened into its parent; null adds nothing
Iterable<Node> (List<Element>, Set<Node>, ...) every node, in iteration order

Any other type does not compile, including:

  • String?, Int? and every other nullable non-node type: decide what null prints, as in {nickname ?: name}.
  • enums, dates, and other objects: convert them explicitly, as in {status.name} or with a formatting function.
  • Sequence<Node>, Array<Node> and Iterable<String>: convert with .toList() or .map { ... } to nodes.
  • Unit, such as a call to a function that returns nothing.

{} that holds only whitespace and comments, such as {/* TODO */}, produces nothing.

<p>{name}, {count} items, {if (vip) "VIP" else ""}</p>
<ul>{tags.map { <li>{it}</li> }}</ul>
{coupon?.let { <p>Coupon: {it.code}</p> }}
{Markup.trusted("<b>bold</b>")}   {/* with import xyz.nillpoe.kmx.Markup */}

Attribute expressions

On an element (a tag whose name starts with a lowercase letter or _), an attribute is one of:

Syntax Emitted Accepted types
name="text" attribute("name", "text") literal; entities decoded
name='text' same literal
name={expr} attribute("name", expr) String?, Number?, Boolean?
name="a{expr}b" attribute("name", ("a" + interpolate(expr) + "b")) String, Number, Char, Boolean in each {}
name attribute("name", true) none
{...expr} attributes(expr) Map<String, String?>

What each value writes:

Value Output
a String name="value", escaped
a Number name=" + toString() + "
true name="name" (the XML form of a boolean attribute)
false or null no attribute, and any earlier value of name on this element is removed
a map entry k to v as attribute(k, v), in map order

Attributes are set in source order and the last value of a name wins. Put {...defaults} first to let later attributes override it, or last to let it override them.

In an interpolated value, {{ and }} are literal braces, entities are decoded in the literal parts, and each {expr} must be non-null. A value without { is a literal. Interpolation never omits the attribute: title="{a}" with a == "" writes title="".

Component attributes

A tag is a component when the first character after its last . is uppercase: <Button>, <ui.Button>. It compiles to a call of a function with that name, Button(...) or ui.Button(...), whose result is inserted like a text expression of type Node.

Syntax Argument
name="text" name = "text"
name={expr} name = expr, any type the parameter takes
name="a{expr}b" name = ("a" + interpolate(expr) + "b")
name name = true
content between the tags children = markup { ... }, of type Node
{...expr} not allowed

Attribute names must be Kotlin identifiers, so data-x is an error on a component. A literal is always a String: write size={3}, not size="3", for an Int parameter. When the tag has no content, or only whitespace that the whitespace rules remove, children is not passed and the parameter's default applies. Other Node parameters take markup literals: <Card header={<h2>Shipping</h2>}>.

Markup literals inside Kotlin

Inside any Kotlin position, < starts a markup literal when both hold:

  1. The next character is a letter, _ or > (a fragment <>...</>).

  2. The < is where an expression can start. That is decided by the text before it, after trailing whitespace:

    • nothing: the start of the Kotlin code, such as {<b>x</b>},
    • an operator or punctuation character other than ], ", ' and .: (, ,, {, =, :, ?, !, &, |, +, -, *, /, %, ;, > (as in ->), <,
    • ), as in if (vip) <b>VIP</b> else null,
    • one of the keywords return, else, in, do, try, throw.

    After any other word (a name, a number, fun, as, is) or after ], a string or a character literal, < is left to Kotlin: a comparison or type arguments.

Code < means
tags.map { <li>{it}</li> } markup (after {)
x -> <b/> markup (after ->)
if (vip) <b>VIP</b> else <i>regular</i> markup, twice
a < b, i<n, count <limit comparison (a space follows, or a name precedes)
emptyList<Node>(), fun <T> f(), x as List<Node> type arguments
(a)<b markup: write (a) < b

A markup literal is one element or one fragment. It compiles to xyz.nillpoe.kmx.markup { ... }, whose static type is Node, not Element. Its content is ordinary markup, so it can hold text, expressions, directives and further literals. A literal directly followed by another < is not two literals: wrap several nodes in a fragment.

Directive expressions

The heads of directives are copied into the matching Kotlin statement:

@if (cond) { ... } else if (cond2) { ... } else { ... }
@for (header) { ... } separator { ... } empty { ... }
@when (subject) {
  cond -> <tag/>
  cond2, cond3 -> { ... }
  else -> <other/>
}
@when {
  cond -> ...
}
@{ statements }
  • A directive starts only with @if, @for or @when followed directly by ( or a space (@when also by {), or with @{. Anything else, such as help@if.example, is text. A tab or line break after the keyword does not start a directive.
  • @if and else if take any Boolean expression. Smart casts from the condition apply inside the block.
  • @for (header) takes any Kotlin for header: item in items, (index, item) in items.withIndex(), i in 1..3.
    • separator { ... } renders before every item except the first. The item's body runs first and the separator is inserted in front of it, so inside the separator the loop variables hold the item that follows.
    • empty { ... } renders when the loop ran zero times. Loop variables are not in scope there.
    • Both are optional and may appear in either order, each once.
  • @when copies the subject and every branch condition verbatim, so is Type, in range, several values separated by ,, and else work as in Kotlin. The when is a statement: Kotlin requires it to be exhaustive for enum, sealed and Boolean subjects. A branch body is a single tag or fragment, or a { ... } block. Comments are allowed between branches.
  • @{ ... } copies statements to its position and writes nothing. It can also call the builder directly: @{ text("raw builder call") }.

Names and scope

Declarations

Declared in Visible in
Header code (val total = ..., fun label(x: Int) = ...) the whole markup
@{ val x = ... } later siblings in the same element or directive block, and their descendants
@for (item in items) the loop body and separator
a lambda in an expression (it, named parameters) that lambda, including markup literals inside it

Implicit receivers

A template's render function runs its code inside, from innermost to outermost:

Receiver Present in Members
the current builder (NodeBuilder, or ElementBuilder inside an element) markup only, not header code text, comment, element, insert, + (unary plus), and for elements attribute, attributes
the props props: Type and props(...) templates the payload's members ({id}, {items})
the render scope (RenderScope) templates and components get, getOrNull, contains, withValue, plus, and extension functions on RenderScope

Kotlin resolves a name in this order: local declarations (including the parameters props and scope), then each receiver from the innermost, then imports and top-level declarations. Therefore:

  • A payload property named like a builder function still works as a property ({text} reads props.text), but a payload function with a builder function's name and a matching signature is shadowed. Call it as props.text(...).
  • Extension functions on RenderScope read like built-ins: {money(total)} calls RenderScope.money.
  • props and scope always name the render parameters. They cannot be used as props(...) parameter names, and a payload member called scope must be read as props.scope.
  • this is the innermost receiver, usually the builder. Use props and scope instead of this.

Builders carry a @DslMarker: inside a nested element, only the innermost builder is an implicit receiver, so a statement cannot add nodes to an outer element by accident.

Signatures

Header props is Members read directly
none Unit none
props: Order the Order passed to render Order's members
props(a: A, b: B = x) a generated Props(val a: A, val b: B = x) a, b
component Card(title: String, children: Node) not defined; a component is fun RenderScope.Card(title: String, children: Node): Node none; title and children are parameters

Nullability and smart casts

  • A nullable value never prints implicitly. In text, only Node? may be null. In attributes, null leaves the attribute out.
  • Smart casts follow Kotlin's rules. @if (note != null) { <p>{note}</p> } compiles when note is stable: a local val, or a val property without a custom getter of a class in the same module. For a payload from another module, use {note?.let { <p>{it}</p> }} or copy it into a header val.
  • Smart casts reach into nested elements and markup literals, because the builder blocks are inline lambdas.

Evaluation order and control flow

  • Header code runs first, then the markup in document order. Within an element, its attributes are evaluated in source order before its children.
  • Every expression runs once per render at its position, except inside @for (once per item, and the separator once per gap, after the body of the item it precedes) and in branches that are not taken.
  • Builder blocks are inline, so break and continue inside a @for body or a @{} block act on the loop, as in Kotlin 2.2 and later. With a separator, continue in the body skips the item and the separator in front of it.
  • Do not return from template code: the render function is an expression that must produce the tree.
  • Exceptions propagate out of render. Invalid XML characters in text or attribute values are reported when the tree is written with toXml, not when it is built.

Whitespace around expressions

Text between tags and expressions follows the JSX rules, applied to each text run separately:

  1. A run without a line break is kept exactly.
  2. A run with line breaks is split into lines. Every line but the first loses its leading spaces and tabs; every line but the last loses its trailing ones. Empty lines are dropped, and the rest are joined with one space.
  3. Inside a directive block, text made only of spaces and tabs between a brace and a tag, an expression or a directive is dropped, as in { <b>Paid</b> }. Text next to a brace keeps its spaces: separator {, } writes , .
Source Text written
<p>Hi {name}!</p> Hi , name, !
<p>\n Hi\n {name}\n</p> Hi, name: no space between them
<p>\n Hi{" "}\n {name}\n</p> Hi, , name
@if (a) { <b/> } no text

Write {" "} for a space the rules remove.

Literal characters and escaping

To write In text In an attribute value
{ } {"{"} {"}"} {{ }}
@ @ (or @@ where a directive would start) @
< &lt; or {"<"} &lt; or <
& &amp;, or a bare & not followed by an entity same
other characters &amp; &lt; &gt; &quot; &apos; &nbsp; &#169; &#xA9; same

Other named entities, such as &copy;, are errors. Entities are decoded in source text only; values from expressions are escaped as they are.

On output, text escapes &, <, > and carriage returns; attribute values also escape ", tab and line feed. Markup.trusted(xml) is the only way to write unescaped XML, and it throws if xml is not well-formed.

Errors

The compiler reports syntax errors as file:line:column message. Common ones:

Message Cause
This code is never closed. a {, ( or string in Kotlin code has no end, often a line comment that swallowed the }
Expected `</b>` to close `<b>` from line N. mismatched closing tag
Unexpected `}`. Write `{"}"}` for the character. a bare } in text
`<` starts a tag. Write `&lt;` or `{"<"}` for the character. a bare < in text
Expected `->` after the branch condition. a @when branch without ->
Unknown entity `&copy;`. ... an entity other than the six named ones
`data-x` is not a parameter name. a component attribute that is not a Kotlin identifier
Components take named attributes; ... a spread on a component
`scope` is reserved inside templates; rename the parameter. props(scope: ...) or props(props: ...)

Type errors come from kotlinc and point at the generated file. Every generated statement that holds template code ends with // file.kmx:line, naming the template line. Typical messages:

kotlinc message Template cause
Argument type mismatch: actual type is 'String?', but 'String' was expected. a nullable value in {} text, or in an interpolated attribute
None of the following candidates is applicable for insert or attribute a type text or attributes do not accept, such as an enum
Unresolved reference a misspelled name, or a missing import
No parameter with name 'children' found. content inside a component that takes no children
'when' expression must be exhaustive a @when over an enum or sealed type that misses a case