Summary
Perry supports tagged-template calls and exposes strings.raw, but the current lowering/codegen materializes fresh cooked/raw arrays on every evaluation. Node/ECMAScript creates one template object per tagged-template call site and reuses it across calls; both the cooked array and its .raw array are frozen.
Libraries use this identity and immutability contract for memoizing parsed template literals, SQL/tag caches, CSS-in-JS caches, and defensive mutation assumptions.
Node behavior
On Node v25.9.0:
let first;
function tag(strings) {
console.log("same:", first === undefined ? "first" : first === strings);
first ||= strings;
console.log("frozen:", Object.isFrozen(strings), Object.isFrozen(strings.raw));
try { strings[0] = "x"; } catch {}
try { strings.raw[0] = "x"; } catch {}
console.log("values:", strings[0], strings.raw[0]);
}
function call(v) {
tag`a\n${v}b`;
}
call(1);
call(2);
prints:
same: first
frozen: true true
values: a\n a\\n
same: true
frozen: true true
values: a\n a\\n
The second call reaches the same tagged-template call site, so the strings object identity is reused. Mutation attempts do not change either array.
Current Perry evidence
Current tagged-template support is per-call materialization:
crates/perry-hir/src/lower/lower_expr.rs lowers a general tagged template to a regular call whose first argument is Expr::TaggedTemplateStrings { cooked, raw }, followed by interpolation values.
crates/perry-codegen/src/expr/instance_misc1.rs lowers Expr::TaggedTemplateStrings by calling lower_array_literal for the cooked array and another lower_array_literal for the raw array each time the expression is evaluated, then registers the pair with js_tagged_template_register_raw.
crates/perry-runtime/src/array/header.rs::js_tagged_template_register_raw() only records a side-table mapping from the cooked array pointer to the raw array pointer and returns the cooked pointer. It does not cache by call site, reuse an existing template object, mark either array frozen/non-extensible, or make the .raw relationship immutable.
That means repeated calls through the same tagged-template expression get distinct arrays, and the template objects are ordinary mutable arrays rather than spec-frozen template objects.
Suggested test surface
Add a Node-suite test like:
let first: any;
function tag(strings: TemplateStringsArray) {
console.log("same:", first === undefined ? "first" : first === strings);
first ||= strings;
console.log("frozen:", Object.isFrozen(strings), Object.isFrozen(strings.raw));
try { (strings as any)[0] = "x"; } catch {}
try { (strings.raw as any)[0] = "x"; } catch {}
console.log("cooked:", strings[0]);
console.log("raw:", strings.raw[0]);
}
function call(v: unknown) {
tag`a\n${v}b`;
}
call(1);
call(2);
Expected properties:
- second call prints
same: true
Object.isFrozen(strings) and Object.isFrozen(strings.raw) are both true
- mutation attempts do not change cooked/raw values
Scope / non-goals
This issue is scoped to tagged-template template-object identity and immutability. It does not track the separate String.raw(...) callable semantics already covered by #2789, and it does not require changing the interpolation-value argument lowering.
Summary
Perry supports tagged-template calls and exposes
strings.raw, but the current lowering/codegen materializes fresh cooked/raw arrays on every evaluation. Node/ECMAScript creates one template object per tagged-template call site and reuses it across calls; both the cooked array and its.rawarray are frozen.Libraries use this identity and immutability contract for memoizing parsed template literals, SQL/tag caches, CSS-in-JS caches, and defensive mutation assumptions.
Node behavior
On Node v25.9.0:
prints:
The second call reaches the same tagged-template call site, so the
stringsobject identity is reused. Mutation attempts do not change either array.Current Perry evidence
Current tagged-template support is per-call materialization:
crates/perry-hir/src/lower/lower_expr.rslowers a general tagged template to a regular call whose first argument isExpr::TaggedTemplateStrings { cooked, raw }, followed by interpolation values.crates/perry-codegen/src/expr/instance_misc1.rslowersExpr::TaggedTemplateStringsby callinglower_array_literalfor the cooked array and anotherlower_array_literalfor the raw array each time the expression is evaluated, then registers the pair withjs_tagged_template_register_raw.crates/perry-runtime/src/array/header.rs::js_tagged_template_register_raw()only records a side-table mapping from the cooked array pointer to the raw array pointer and returns the cooked pointer. It does not cache by call site, reuse an existing template object, mark either array frozen/non-extensible, or make the.rawrelationship immutable.That means repeated calls through the same tagged-template expression get distinct arrays, and the template objects are ordinary mutable arrays rather than spec-frozen template objects.
Suggested test surface
Add a Node-suite test like:
Expected properties:
same: trueObject.isFrozen(strings)andObject.isFrozen(strings.raw)are both trueScope / non-goals
This issue is scoped to tagged-template template-object identity and immutability. It does not track the separate
String.raw(...)callable semantics already covered by #2789, and it does not require changing the interpolation-value argument lowering.