Wado

WEP: Tagged Template Literals

Context

A template string `id = ${id}` renders every interpolation through Display. That is the right default for text and the wrong one for every structured sink: a SQL query wants ${id} bound as a parameter, an HTML fragment wants ${name} escaped for the context the literal text put it in, a log line wants ${user.id} as a typed field beside the message, a URL wants each segment percent-encoded. Each of these sinks is a function of two things the untagged template throws away — the literal text around a hole, and the hole's value in its own type.

A tag names that function: sql`SELECT * FROM users WHERE id = ${id}` hands sql the literal segments and the typed values instead of a rendered String. The surface is JavaScript's; the model underneath is Wado's own.

The original design of this feature was a compile-time decoder — a tag was an effect-free fn(String) -> T run by the compiler, motivated by embedding base64 blobs and validating regex literals. Both motivations have since been served elsewhere: b"…" byte strings and #include_bytes embed binary as a constant data segment, and Kiln validates file-shaped DSL inputs. What no other mechanism serves is the interpolation case above, and it does not need compile-time execution at all. So this WEP puts the tag's job where the remaining demand is, and treats folding at compile time as an optimization the optimizer may perform, never as the semantics.

Three things the language has grown since decide the shape:

Together they let the static part of a template — its literal segments, its specifiers, its arity and the type at each hole — live in a type, so that a tag body reading them is reading constants. That is what makes a tagged template cost what the untagged one costs.

Decision

Surface

let q = sql`SELECT * FROM users WHERE id = ${id} AND name = ${user.name}`;
let s = String::raw`${dir}\bin\run.exe`;

A tag is a path expression — a function name or a static method path — written directly before the backtick, with no whitespace between. The backtick is a postfix on the path, at the precedence of a call. Only a path may be a tag: a call result, a closure, or a parenthesized expression is not one, so f()`x` and (g)`x` are syntax errors.

The template body is lexed exactly as an untagged one: ${expr} and ${expr:spec} holes, { and } literal, \$ the only escape a hole needs. The tag changes nothing about how the literal reads, only about what it means.

A tag is an ordinary function — any effects, any return type — whose first parameter is satisfied by ReflectTemplate. The template is its one written argument, and ordinary call resolution fills the trailing parameters that declare a default. Nothing about being a tag appears on the declaration; a function is a tag because a call site wrote it before a backtick. A first parameter of any other type is reported as a tag's missing bound, naming the type it takes, since the template's own type is one no source spells.

The template type

Each template literal that carries a tag denotes a value of a compiler-synthesized struct type, unique to that template's static shape, holding one field per hole. The shape is the tuple of (raw segments, specifiers, hole source texts, hole types): two templates with the same shape denote the same type and share every instantiation of the tag. The source texts are part of the shape because TemplateHole::source() is a constant, so tag`${a}` and tag`${b}` are two types even where a and b share one. The type is anonymous — nothing in the source can name it — and is reached only through the bound the tag declares.

The name the type shows a reader, in a diagnostic and as Reflect::type_name(), is the template's text with each hole spelled as its type and specifier, cut to 50 characters with ...: sql`id = ${id:04}` is named `id = ${i32:04}`. The name describes the shape; it is not its identity, which also holds the source texts.

A hole's field carries its value without copying it and without boxing it. A type whose reference is a bare GC handle — struct, List, String, i128 — is held as &V; a type whose reference the compiler would box — a primitive, an enum, flags, a variant, a function — is held as V, since a scalar copy is free and a box is an allocation. One predicate decides, the one Reference Representation boxes by. The tag never sees the difference: TemplateHole::get answers V either way.

The type is one of the reflected kinds, under the same seal as the five declaration kinds: compiler-synthesized, a user impl a compile error, callable only where T is concrete. It is pub like the rest of the family: a tag names it in its own bound, and tags are written in ordinary packages. The seal is what makes that safe: only the compiler can satisfy the bound, so naming it is all a caller gains.

#[compiler_item("reflect_template")]
pub trait ReflectTemplate: Reflect {
    /// The hole types as a tuple `[V_0, V_1, …]` — the payload pack.
    type Holes;

    /// The per-hole member tuple `[TemplateHole<Self, V_0>, TemplateHole<Self, V_1>, …]`.
    type Members;

    /// Returns the per-hole members.
    fn members() -> Self::Members;

    /// The literal text after the last hole; the whole template when it has
    /// none. Escapes processed.
    fn tail() -> String;

    /// `tail()` with escapes preserved.
    fn raw_tail() -> String;
}

A hole is a member handle like StructField: sealed, fields private, minted only by members(), and not itself reflectable. Like every member handle it is prelude API, so a helper that takes one names it.

#[compiler_item("template_hole")]
pub struct TemplateHole<T, V> { … }

impl<T, V> TemplateHole<T, V> {
    /// The hole's position in the template, from 0.
    pub fn index(&self) -> i32;

    /// The literal text between the previous hole (or the start) and this one,
    /// escapes processed.
    pub fn lit(&self) -> String;

    /// `lit()` with escapes preserved: `\n` is a backslash and an `n`.
    pub fn raw(&self) -> String;

    /// The hole's value in `t`. A read-only use shares the storage, as a
    /// `StructField::get` does; only a mutation copies.
    pub fn get(&self, t: &T) -> V;

    /// The hole's expression as written: `"user.name"`.
    pub fn source(&self) -> String;

    /// Whether the hole wrote a `:spec`.
    pub fn has_spec(&self) -> bool;

    /// Renders the value as the untagged template would: the trait method the
    /// specifier's type selects (`Display::fmt` when it names none), on a
    /// `Formatter` carrying the specifier's fill, alignment, sign, width and
    /// precision over `f`'s buffer.
    pub fn fmt(&self, t: &T, f: &mut Formatter);
}

Every accessor but get and fmt returns a constant, and fmt's body is synthesized per hole, so the choice of trait method — a compile-time decision, as in an untagged template — is closed inside it. The tag never sees a pre-rendered String it did not ask for, and a tag that rejects specifiers reads has_spec().

The literal segments are the same string in two spellings; both are constants, and the one a tag never reads is dead code. That is the whole cooked-versus-raw mechanism: no mode, no marker type, and no inspection of the tag's signature. String::raw is a tag that reads raw() and raw_tail(). A raw segment is still a template segment, so it spells only the escapes the language has: String::raw`a\nb` is a\nb as written, and \p is the error it is in any template.

Desugaring

sql`SELECT * FROM users WHERE id = ${id} AND name = ${user.name}`

lowers to

$tagged: {
    sql($Tmpl_a3f1 { h0: id, h1: &user.name })
}

id is an i32, so its hole holds the value; user.name is a String, so its hole holds the handle. A hole that names storage is read or borrowed where it stands: a local, a global, or a field or element path rooted at one. Any other hole expression is evaluated once into a local of the block, which the literal then reads or borrows.

The holes are evaluated in source order, the order an untagged template evaluates them, and the literal is built after the last of them. So a hole is read in situ only while nothing after it can write that storage. A hole that names storage and is followed by one that does not is bound at its own position too, and the tag then sees the value the storage held there. That is what holds format`${a} ${bump(&mut a)}` to what `${a} ${bump(&mut a)}` renders.

The struct literal is the only place the synthesized type is built. Passing it by value copies scalars and handles, never the storage a handle points at. Borrowing a local of a handle type marks nothing on that local: only a box-target type's local is retagged by a borrow, and those holes are held by value.

The synthesized impl ReflectTemplate carries the segments, specifiers and sources as literals in its members() and tail() bodies, and TemplateHole::get projects the field at the hole's index, dereferencing a handle field.

Writing a tag

A tag binds its template through ReflectTemplate, binds the hole pack off Holes, and walks members():

fn sql<T: ReflectTemplate<Holes = [..V]>, ..V: ToSqlParam>(t: T) -> SqlQuery {
    let mut query = "";
    let mut params: List<SqlParam> = [];
    for let h of ReflectTemplate::<T>::members() {
        query.push_str(h.lit());
        query.push_str("?");
        params.push(h.get(&t).to_sql_param());
    }
    query.push_str(ReflectTemplate::<T>::tail());
    return SqlQuery { query, params };
}

..V: ToSqlParam is what makes sql`… ${some_struct}` a bound error naming the type, rather than a missing-method error inside the unrolled body. The walk is tuple for-of, so a state carried across holes is a local, and one per hole is the .enumerate() index:

fn html<T: ReflectTemplate<Holes = [..V]>, ..V: Display>(t: T) -> Html {
    let mut out = "";
    let mut ctx = HtmlContext::Text;
    for let h of ReflectTemplate::<T>::members() {
        out.push_str(h.lit());
        ctx = ctx.advance(&h.lit());      // attribute? script? text?
        ctx.escape_into(h.get(&t), &mut out);
    }
    out.push_str(ReflectTemplate::<T>::tail());
    return Html { out };
}

source() gives a structured sink its field names for free:

fn info<T: ReflectTemplate<Holes = [..V]>, ..V: Serialize>(t: T) with Log {
    let mut msg = "";
    let mut fields = FieldMap::new();
    for let h of ReflectTemplate::<T>::members() {
        msg.push_str(h.lit());
        h.fmt(&t, &mut Formatter::new(&mut msg));
        fields.insert(h.source(), h.get(&t));   // "user.id" => 42
    }
    msg.push_str(ReflectTemplate::<T>::tail());
    emit(msg, fields);
}

A tag with no use for holes constrains the pack to the empty tuple.

fn regex<T: ReflectTemplate<Holes = []>>(t: T) -> Regex {
    return Regex::compile(ReflectTemplate::<T>::tail()).unwrap();
}

members() walks a pack, so those are the two spellings it can be called under. A concrete tuple (Holes = [List<i32>]) binds no pack, and the call is an error naming the binding it needs.

A hole's type may not mention a type parameter of the enclosing item: the shape would have to be generic over it, and a shape is minted once. A generic body reaches a tag by taking the rendered value, or a concrete one, from its caller. The untagged template has no shape, so `${v}` over a v: X is fine where format`${v}` is not.

The untagged template is a tag

An untagged template means what the prelude's format tag means:

fn format<T: ReflectTemplate<Holes = [..V]>, ..V>(t: T) -> String {
    let mut out = "";
    for let h of ReflectTemplate::<T>::members() {
        out.push_str(h.lit());
        h.fmt(&t, &mut Formatter::new(&mut out));
    }
    out.push_str(ReflectTemplate::<T>::tail());
    return out;
}

The compiler keeps its direct lowering for the untagged case (String Template Desugaring); the equivalence is a property the fixtures hold it to, not a path it takes.

Cost

What the tag body compiles to, once monomorphized, inlined and folded:

The residue is the code a hand-written builder would be: one constant append per segment and one typed operation per hole. That is the design's claim, and a wir_expect fixture holds a tag to it. Where a read fails to fold, that is the optimizer's gap to close, not a reason to move the segments into a runtime value.

Each distinct template shape instantiates the tag once, as inlining a builder at each site would. Two sites with the same shape share one instantiation; two that differ only in how a hole is spelled do not.

Compile-time evaluation

A tag call is a call. Whether it runs at compile time is decided the way every other call is: niri folds a pure call over constant arguments when it can, and a wasm-CTFE backend may fold more once it exists (NIR Interpreter). A hole-less template under a pure tag — regex`^[a-z]+$` — is a candidate; a template with holes is a candidate only where the holes are constant. Nothing in the tag's meaning depends on the outcome, and a tag may carry effects. A program that wants a hole-less tag evaluated once writes a global, which the lazy-init path already guarantees.

Deliberate omissions

Alternatives considered

Type-level strings. The segments could ride as type arguments — Template<"SELECT ", " AND ", [..V]> — instead of in a synthesized nominal type. It is the more general mechanism: a Route<"/users/{id}"> or a Regex<"…"> would name its literal in its type, and #[param] could carry a value parameter the same way. It needs value parameters in the type system and a second pack alongside ..V, neither of which exists, and it buys a tagged template nothing the synthesized type does not. Should it arrive, the synthesized type can be re-expressed as an instance of it without changing what a tag can observe, since the type is unnameable.

Compile-time execution as the semantics — the original design. A tag was an effect-free fn(String) -> T the compiler ran. It required a whole-program evaluator the toolchain does not have, restricted tags to pure functions, and solved a problem (base64`…`) that literals and #include_bytes since solved better. Kept as an optimization opportunity, dropped as a promise.

Consequences

Implementation

The template type is an anonymous struct — the precedent is the struct literal { x: 1, y: "s" }, which already satisfies ReflectStruct through the same machinery (tests/fixtures/reflect_anon_struct.wado) — and ReflectTemplate follows ReflectStruct at every step of the reflect pipeline. Nothing below introduces a mechanism; each item names the existing one it extends.

Type table (tir.rs)

Prelude (lib/core/prelude/traits.wado, lib/core/builtin.wado)

Parser and AST

Annotate (elaborator/tagged_template.rs)

resolve_tagged_template records facts on the expression's AstId and returns the tag's result type:

  1. Resolve each hole expression with no expected type; validate segments through the same unescape gate resolve_template_string runs.
  2. Build the shape, intern it, make_struct(StructDef::Anon(shape)), and on a first sighting register StructFieldInfo under anon_struct_fields and push the TirStruct onto pending_anonymous_structs — the minting resolve_anonymous_struct_literal does, factored into one helper both call.
  3. Resolve the tag as a one-argument call whose argument type is known and has no AST: resolve_call_with_args is resolve_call over arguments already typed, keyed by the template's AstId, so signature lookup, instantiation, the projection of ..V from Holes, the bound check and the StaticMethodDispatch fact are the ones a spelled call records. A tag of any other arity, or one the call path cannot find, is that path's diagnostic. A tag naming a value is refused before the call is resolved, and one that resolves without a dispatch fact after it.
  4. Record the template type under the template's AstId (tagged_templates); place-ness is reify's to read off the AST.
  5. The tag identifier's use→def edge falls out of the call resolution.

Reify

reify_tagged_template reifies each hole in order, binding one that is not a place to a let $hole_{n}_{k} local, and builds the Call the dispatch fact names over a StructLiteral whose field k is the place or local, borrowed when the field type is &V_k. With locals the call sits in a labeled block that breaks with its value. The TirStruct reaches the module through the pending list reify already drains.

{n} is FunctionContext::fresh_serial, taken before the holes are reified. The serial advances on read, so a template nested inside a hole mints its own $hole_{n+1}_{k} and $tagged_{n+1} instead of the enclosing template's names (issue #1987).

Reflect resolution (elaborator/reflect.rs, solver_bridge.rs, trait_query.rs)

Synthesis (synthesis/traits.rs, synthesis/template.rs)

Fixtures

Order

Prelude and registry first, so every later step compiles against the trait; then the reflect resolution, since the call-site bound check needs the kind before any tag call can be typed; then parser, annotate and reify; then synthesis and the fold; then the prelude tags and fixtures.

Known gaps

References