Repository navigation
A lying string-declared local silently drops an operand: t + "x" returns "x", and s + 7 concatenates instead of adding #7837
Description
Summary
A string-declared local that actually holds a number produces two different silent wrong answers, one of which drops an operand entirely. TypeScript types are erased at runtime, so a declared type is not a proof — but codegen trusts it here.
const src: any = 42;
const s: string = src; // declared string, actually a number
console.log(s + 7); // node: 49 perry: 427
const t: string = (99 as any);
console.log(t + "x"); // node: "99x" perry: "x" <-- 99 DROPPEDnode --experimental-strip-types lyinglocal.ts perry 0.5.1463 (1ee158d27)
49 427
99x x
Reproduced on current main with PERRY_NO_AUTO_OPTIMIZE=1. No throw, no diagnostic, exit 0. Committed as gc-handoff/m0810/lyinglocal.ts.
Two separate defects
1. Wrong operator. s + 7 where s holds the number 42 must be a numeric add (49). Perry lowers it as string concatenation because s is declared string, giving "42" + "7" = "427".
2. An operand is discarded. t + "x" where t holds 99 must be "99x". Perry returns "x". The non-string operand is not coerced and not concatenated — it vanishes. This is js_string_concat_box's non-string path returning the equivalent of an empty string rather than delegating to js_dynamic_string_or_number_add.
Defect 2 is the more dangerous of the two: a wrong value is at least still a value, whereas silently deleting data from a string is the shape that corrupts logs, keys, and serialized output without ever looking wrong.
Why the usual reasoning misses it
is_definitely_string_expr's LocalGet arm trusts a declared type. A string-declared field or a (string, number) parameter pair do not reach the buggy helper on 1ee158d27 — I checked all three shapes, and only the local reaches it:
const s: string = "ab"; const n: number = 42; console.log(s + n); // "ab42" correct
const o: any = { t: "x" }; console.log(o.t + 7); // "x7" correct
function f(a: string, b: number) { return a + b; } console.log(f("q", 9)); // "q9" correctSo a reviewer probing the obvious spellings concludes the surface is safe. It is the lying local that gets through.
Relationship to #7831
This is the mirror image of #7831 ("a declared numeric type is not a proof that the value is a number"). That PR fixes the numeric direction — where a declared number holding a string makes + produce a bad add. This is the string direction, and both stem from the same premise: an erased TypeScript annotation is a hint, not a runtime proof. Worth fixing together, or at least deciding the policy once and applying it to both predicates.
PR #7835 already fixes defect 2 as a prerequisite for widening the concat fast path, and adds the delegation to js_dynamic_string_or_number_add. Defect 1 — choosing concat over numeric add on a lying declared local — is not addressed there and needs its own decision.
Found by
Cross-checking a sub-agent's claim during the 2026-08-11 performance round. I first probed a declared field and a parameter pair, concluded the bug was latent, and published that. The agent pointed out LocalGet trusts declared types, which sent me back to test the local form — where it is live. Worth recording as a method note: three negative probes did not establish absence, because none of them exercised the arm that trusts the annotation.
Activity
docs: the concat_box wrong answer IS live; scope #7835 against #7837
perf(codegen,runtime): let a declared `string` pick the concat loweri…
fix(codegen,runtime): a declared `string` is not a proof, so it may n…
proggeramlug commentedon Aug 11, 2026
Rolled up into #7846 — this is one of four instances of a single root: a type/kind proof attached to a local binding, used at sites that a later write to that binding does not dominate. The shared tell across all four is "correct inline or at the definition site, wrong once the value lives in a local". #7846 argues for a detector rather than four independent patches.
test(codegen): pin that a declared-type lie cannot reach the numeric …
fix(codegen,runtime): a declared `string` is not a proof, so it may n…
perf(codegen): put the numeric array push's GC bookkeeping behind one…
fix(codegen,runtime): a declared `string` is not a proof, so it may n…
fix(codegen,runtime): a declared `string` is not a proof, so it may n…
fix(codegen,runtime): a declared `string` is not a proof, so it may n…