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:
- Variadic type parameters and tuple enumeration walk a heterogeneous tuple with one body per element, unrolled.
- Reflection exposes a declaration's structure as a sealed, compiler-synthesized trait whose member handles are constants, so a walk over them folds to the code a hand-written body would be.
- Constant object globalization
hoists a closed constant expression to a global, and
niriprojects fields back out of one.
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:
for let h of members()is unrolled — one straight-line block per hole.members()is a closed constant expression: globalization hoists it, and everylit()/raw()/source()/has_spec()is a field projected out of that global, whichnirifolds to the literal.push_str(h.lit())is the same instruction sequence aspush_str("SELECT …").get()is a projection of the template struct's field at a constant index, folded to a direct field read after inlining, asStructField::getis. Its result binds read-only in every tag above, so the value-copy planner shares the storage rather than copying it — the pathcore:serde'sf.get(self)already takes.- A hole holds a handle or a scalar, so nothing a handle points at is copied
into the tag, and no last-use analysis is asked to elide a copy. A hole bound
at its own position for evaluation order copies what a
letwould.
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
- No tag on a non-path expression, and no whitespace between the tag and the
backtick: the grammar stays a postfix on a path, which is what the reserved
<position andwado formatcan hold. - No compile-time execution in the semantics, per the above.
- No
Template<..V>runtime value holding the segments as an array. The segments as data cost an allocation and an indexed load per segment at every evaluation, and left the specifier as either a lost type or a pre-rendered string. The type carries them instead. - No sink trait (
begin/lit/hole/finish) as the tag protocol. It reaches the same cost, but a tag that needs the whole template — to count holes before emitting, or to read a later segment — cannot be written against it, and it would make a tag a type rather than a function. - No literal index into
MembersorHoles: a pack is walked, never indexed (WEP 2026-03-14).
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
- A tagged template costs what an untagged one costs: constant appends and typed operations, no runtime segment table, no value copies.
- Tag authors write one generic function, in Wado, against traits the language already has. SQL, HTML with contextual escaping, structured logging, URL building and raw strings are library code.
- The
ReflectTemplatekind is a sixth entry underReflect, synthesized per template shape rather than per declaration.TemplateHolejoins the other member handles under the same seal. - Type checking of the walk body is at monomorphization, as it is for every
pack walk. A pack bound (
..V: Trait) makes the common failure a bound error at the call site; a failure inside the body needs the call-site, element and body locations the variadic WEP already asks for. Enforcing that bound closed a gap every projected pack had: the bound check ran before the projection made the pack concrete and never asked again, so..F: TraitoffFieldTypesheld for any struct. - The binary grows by one tag instantiation per template shape, as it would by inlining a builder at each site.
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)
AnonShape::Template(TemplateShape)besideFieldsandSynthetic.TemplateShape { segments: Vec<String>, holes: Vec<TemplateHole> }, the segments raw, each hole{ ty, spec: Option<String>, source }. The shape is the interning key, so two sites of one shape reach oneAnonStructIdthroughintern_shape, as struct literals do.- The struct's fields are derived from the shape:
h{k}typedhole_field_type(V_k), which is&V_kwhereV_kis not a box target andV_kwhere it is. The box-target predicate moves out oflower::plan::boxing'screate_needed_box_typesinto oneTypeTable::is_boxed_reference_target, which the boxing pass then consumes, so the two cannot drift. It is the single predicate Reference Representation calls for. reflect_kindanswersReflectTemplatefor aStruct { def: Anon(shape) }whose shape is a template;is_sealed_reflect_memberadds theTemplateHolehandle.anon_struct_manglerenders a template shape under a$tmplprefix over the shape's hash, andanon_struct_namerenders the reader's name above, whichReflect::type_name()answers. The mangle is fixed when the shape is interned: it renders the hole types, and erasure later redirects a newtype among them to its base, so a second rendering would name a struct WIR never registered — the same failure a struct literal with a newtype field had.
Prelude (lib/core/prelude/traits.wado, lib/core/builtin.wado)
#[compiler_item("reflect_template")] pub trait ReflectTemplate: ReflectwithHoles,Members,members(),tail(),raw_tail(), each method carrying its owncompiler_item.#[compiler_item("template_hole")] pub struct TemplateHole<T, V> { index, lit, raw, source, has_spec }with the constant accessors reading fields, and two bridged ones:getisbuiltin::hole_get::<T, V>(t, self.index),fmtisbuiltin::hole_fmt::<T>(t, self.index, f). Both builtins are bodyless markers besidestruct_field_get.core:preludere-exports it with the other member handles.compiler_item.rsgainsReflectTemplate,ReflectTemplateHoleand the three method items, inALL,attr_name,expected_kind,is_required.formatincore:preludeandString::rawin the string module, written as the tags above.
Parser and AST
Expr::TaggedTemplate(Box<TaggedTemplateExpr { id, tag: Expr, template: TemplateStringExpr, span }>), the tag always anExpr::Ident. A new variant rather than a field onTemplateStringExpr: a tagged template is a call, and the arms that treat a template as a string literal (overload classification'sArgClass::StrLit, newtype literal coercion) must not see it. Every exhaustive match then names the arm; the visitor walks the tag so hover, references and comment attachment reach it.parse_postfix_exprtakes aTemplateStringLitarm: the receiver must beExpr::Identandexpr.span().end == peek().span.start. Whitespace and comments never reach the token stream, so a byte gap is the adjacency test. A non-path receiver is a syntax error naming the rule; a gap falls through to the ordinary unexpected-token error.unparseprints the tag then the template with nothing between, in both printers.Wado.g4addstaggedTemplate : (tagOwner '::')* tagName templateStringtoprimaryandprimaryNoStruct; the grammar cannot see the gap, so the whitespace case is acompile_errorfixture undercheck-grammar's second invariant.Wado.highlights.scmcapturestagNameas@function, and the LSP's call heuristic reads an adjacent template as a call, so both sides colour the tag alike.
Annotate (elaborator/tagged_template.rs)
resolve_tagged_template records facts on the expression's AstId and returns
the tag's result type:
- Resolve each hole expression with no expected type; validate segments through
the same unescape gate
resolve_template_stringruns. - Build the shape, intern it,
make_struct(StructDef::Anon(shape)), and on a first sighting registerStructFieldInfounderanon_struct_fieldsand push theTirStructontopending_anonymous_structs— the mintingresolve_anonymous_struct_literaldoes, factored into one helper both call. - Resolve the tag as a one-argument call whose argument type is known and has
no AST:
resolve_call_with_argsisresolve_callover arguments already typed, keyed by the template'sAstId, so signature lookup, instantiation, the projection of..VfromHoles, the bound check and theStaticMethodDispatchfact 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. - Record the template type under the template's
AstId(tagged_templates); place-ness is reify's to read off the AST. - 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)
ReflectDispatch::Templateand aTemplateMethods::resolvereading the method names off the registry, reached through theReflectTemplatearm ofreflect_dispatch_of.reflect_template_holesreads hole types off the shape. The concrete resolver typesmembers()aspayload_members_ty(ReflectTemplateHole, T, holes)andtail()/raw_tail()asString. The generic resolver, for a body written againstT: ReflectTemplate<Holes = [..V]>, typesmembers()as the mapped pack[..TemplateHole<T, V>]throughpayload_member_pack_bound_ty, andemit_missing_pack_boundspellsHoles = [..V].concrete_reflect_assoc_typegains the template arm, so a call site projectsHolesandMembersbefore synthesis has registered them.solver_bridge:ReflectTemplatejoinsREFLECT; every template shape lowers under the oneDeclKey::TemplateShapehead with its hole types as the arguments, andstate_reflect_factsstates it visible from every module.trait_query:OnBoundTrait::ReflectTemplate,on_bound_of, andreflect_members_visibleanswering true.- The seal list in
orchestration.rsnamesReflectTemplate.
Synthesis (synthesis/traits.rs, synthesis/template.rs)
collect_reflect_targetsroutes a template-shapedTirStructtogenerate_template_reflect_implsinstead of the struct kind. Per shape it emitstype_nameandwire_name_policyunder the root, registersHoles = [V_k]andMembers = [TemplateHole<T, V_k>], and emitsmembers()throughgenerate_template_members_fnwith oneTemplateHoleliteral per hole (litcooked throughunescape_template_string,rawverbatim), plustail()andraw_tail()returning literals. The impl is recorded intoTraitEnvlike the struct kind's.- Two bridges per shape, minted beside
generate_field_bridge_helpers:$hole_get$<shape>$<V>(one per erased hole type,match indexover the holes of that type, dereferencing a handle field) and$hole_fmt$<shape>(match index, each arm the interpolationbuild_template_blockwould emit for that hole:trait_fmt_callon the hole's value with theFormatterbuild_formatter_literalbuilds overf.buf, orfitself when the specifier sets no field).$hole_fmtis minted inline-always: its index is a constant at every siteTemplateHole::fmtreaches oncemembers()folds, so the splice keeps one arm where a call would keep the whole dispatch, which the threshold refuses. lower/translate.rsfoldsbuiltin::hole_get/hole_fmtcalls to the bridge names beside itsstruct_field_getarm;name.rsowns both names.- A template shape is never generic, so
reflect_bridge's post-monomorphization minting is not involved.
Fixtures
tagged_template_sql.wado: typed holes through aToParampack bound.wir_expect:O2pins what is left — one constant byte array per segment, and oneParamconstruction per hole reading the hole field directly.wir_not_expect:O2denies$hole_get,$hole_fmt,members,to_paramand anypush_strcall.tagged_template_html.wado: state carried across holes.tagged_template_members.wado: everyTemplateHoleaccessor and the tails, with and without holes.tagged_template_holes.wado: hole evaluation order and count, a scalar hole's copy, a handle hole's sharing, two sites of one shape.tagged_template_hole_order.wado: a hole a later hole mutates carries the value it had at its own position.tagged_template_qualified_tag.wado: a static-method path on an imported type as the tag.tagged_template_tag_default_arg.wado: a tag whose trailing parameter has a default, filled at the site.tagged_template_format_equiv.wado:formatagainst the untagged form over the specifier matrix.tagged_template_string_raw.wado:String::rawkeeps escapes verbatim.tagged_template_type_name.wado: the reader's name, specifiers and the cut included.anon_struct_newtype_field.wado,reflect_pack_bound_free_fn_error.wado: the two pre-existing defects the work surfaced, pinned.- Errors: a non-path tag, a path that is neither a function nor a static
method, an undefined tag, a tag whose parameter is not
ReflectTemplate, whitespace before the backtick in statement and argument position, a tag of the wrong arity, an unsatisfied..Vbound naming the hole type, a concreteHolesbinding, a hole whose type mentions a type parameter, a tag call whose effect the caller leaves undeclared. Each is one diagnostic.
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
- A tag that carries a state across holes by scanning the literal segments
folds to the per-hole decision only while that scan fits the inliner's
budget. That is the
htmlexample's shape, and the one a code generator needs. Writing the per-character transition as its own function gets it back under the budget at any state count, so the tag author has a shape to know. The gap is the optimizer's, and the optimizer guide carries it. - A hole whose type mentions the enclosing function's type parameter. The shape would have to be generic over that parameter and instantiated with the function, which an anonymous struct is not today; until then the site is a diagnostic, and a generic body reaches a tag by passing a concrete value.
- A shape is interned per module, as struct literals are, so two modules writing one template instantiate the tag twice. Cross-module sharing is a size optimization the interner's key can take later.
- A concrete tuple binding for
Holes(Holes = [List<i32>]). It fixes the arity, somembers()would have a fixed type, but no tag has needed one;FieldTypesandCasePayloadsshare the limit. - A tag with a turbofish (
f::<T>`…`): the bare-turbofish path leaves the identifier's span at the name, so adjacency fails and the site is a syntax error. Nothing needs it yet. - A tag that is not a function or a static method — a variant case, a closure-typed binding — is a diagnostic. Reify rebuilds a tag call only from a static dispatch, and neither records one.
- A tag on a type parameter (
T::tag`…`) works, but a mismatch between the template and the method's parameter is not reported: that path checks no argument, whatever the call's spelling. Pinned astype_param_static_call_arg_check_todo.wado. - Whether
match typegains atemplatearm for theTemplatecase Total Reflection gives the type. - A tag reached through a namespace import (
ns::Sql::q`…`) or answered by a blanket impl (i32::label`…`). Neither is a tagged-template gap: the same static method fails the same way when spelled as a call, and both are pinned as#[TODO]—reflect_pack_static_namespace_import_todo.wadoandreflect_pack_blanket_static_todo.wado. - Two shapes that differ only in their holes' source texts
(
tag`${a}`andtag`${b}`,aandbof one type) are two types with one name. A diagnostic relating them names the same type on both sides. - The type has no
Inspect, though every other type does. Anassertthat captures a template's value fails to compile (assert id`n=${n}` == id`n=${n}`;), and the error names the type by its internal symbol ($tmpl$…) rather than by the name above. How the value prints is open: as the template it was written as, or as a struct of its holes. - The monomorphization-time diagnostics the variadic WEP lists — call site, element index, body location — are what a failing tag body reports through, and are still open there.
Related WEPs
- String Template Desugaring — the untagged lowering, and
TemplateHole::fmt's per-specifier body - Compile-Time Tuple Enumeration — the walk
- Variadic Type Parameters — the pack bound and pack binding
- Library-Defined Derivation over
Reflect*— the kind and member-handle model this joins - Constant Object Globalization — why
members()is free - Template Format Specifiers — what
has_spec()andfmt()reflect - Markup Dialect — the HTML consumer
References
- MDN: Template literals
- Scala: String interpolation — interpolators as library methods over a
StringContext
