The rule we build by
Every time a Kittine project runs into a wall the language can't yet express, our default move is to fix the compiler — not route around the gap in application code. A language stays honest, and stays worth using, when it grows from real pressure instead of a roadmap written in isolation. The first proof of that rule in practice was kittylang, a small interactive app written entirely in Kittine, which needed one very ordinary thing: putting a live counter value next to a label.
"label: " + count where count is a reactive signal — and have the displayed text update automatically whenever the signal changes. Before this feature existed, there was no clean way to write that directly in a .kitty file.Why this is harder than it looks
Kittine tracks, for every variable it has already seen in the current component, whether that variable is a plain value or a reactive signal. A signal read normally needs .get() to pull its current value inside a reactive closure. A string literal has no signal at all. Naively, concatenating the two would need special-casing every combination of literal-plus-signal, signal-plus-literal, and literal-plus-literal — exactly the kind of asymmetry that makes a language's rules hard to hold in your head.
The actual fix is narrower and more elegant: teach the meaning of + to depend on whether either side is a string literal, not on what type the other side happens to be.
What actually changed in the compiler
The rule that shipped: if neither side of a + is a string literal, it still lowers to Rust's ordinary numeric +, exactly as before — <{count}> >> count + 1 becomes *n += 1. But if either side is a string literal, the whole expression lowers to Rust's format!("{}{}", left, right), Display-formatting both operands. That one condition is enough — a variable on the other side never needs to be a string itself, because numbers and other displayable values interpolate naturally through Display:
count whose value is 5):'Taps: ' + <{count}> → format!("{}{}", "Taps: ", count.get()) → "Taps: 5"<{mood}> + '!' → format!("{}{}", mood.get(), "!") → e.g. "Curious!"
Chains resolve the same way, left-associatively, so 'a' + x + 'b' parses as ('a' + x) + 'b' — the inner + already sees a string literal and becomes a format!, and the outer + sees 'b' as a literal and does the same. The whole chain concatenates correctly regardless of what x actually is, with no special-case needed for three-way concatenation.
Where this had to work
The feature wasn't scoped narrowly to one kind of expression. It works anywhere a Kittine expression is valid: craft<...> arguments, JSX { expr } interpolations, variable declarations and mutations, and inline event-handler assignments. That breadth is deliberate — a language feature that only works in one syntactic position isn't really a language feature, it's a special case waiting to surprise someone.
A subtlety worth knowing
Because Kittine's lexer greedily matches multi-character tokens like >> before falling back to single characters, a +-expression ending in a variable read right before a closing > can be parsed differently than expected if you're not careful with spacing — a real, documented edge case in the language reference rather than a hidden trap. It's the kind of interaction that only surfaces once a feature is actually load-bearing in real code, which is itself part of why building compiler features against real projects — instead of hypothetical ones — tends to catch the sharp edges before users do.
The bigger pattern
This is the smallest possible case study in how Kittine is meant to evolve: a real, ordinary need shows up in a real project; the fix lives in the lexer/parser/codegen layer of the compiler itself; and every existing .kitty file keeps compiling exactly as before, because the new behavior only activates in the presence of a string literal that wasn't legally combinable with anything before. Nothing about the old numeric + path changed. The language just got wider.