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 DROPPED
node --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" correct
So 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.
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.Reproduced on current
mainwithPERRY_NO_AUTO_OPTIMIZE=1. No throw, no diagnostic, exit 0. Committed asgc-handoff/m0810/lyinglocal.ts.Two separate defects
1. Wrong operator.
s + 7wheresholds the number42must be a numeric add (49). Perry lowers it as string concatenation becausesis declaredstring, giving"42" + "7"="427".2. An operand is discarded.
t + "x"wheretholds99must be"99x". Perry returns"x". The non-string operand is not coerced and not concatenated — it vanishes. This isjs_string_concat_box's non-string path returning the equivalent of an empty string rather than delegating tojs_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'sLocalGetarm trusts a declared type. Astring-declared field or a(string, number)parameter pair do not reach the buggy helper on1ee158d27— I checked all three shapes, and only the local reaches it:So 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
numberholding 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
LocalGettrusts 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.