Skip to content

A lying string-declared local silently drops an operand: t + "x" returns "x", and s + 7 concatenates instead of adding #7837

Description

@proggeramlug

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions