Read undeclared literal keys as undefined and keep filled-in JavaScript vars dynamic

This commit is contained in:
Chris Tate
2026-10-25 23:05:10 -05:00
parent 141d612907
commit 42e5d28684
19 changed files with 200 additions and 2 deletions
@@ -1,4 +1,5 @@
/* Element reads and writes (`a[i]`, `a[i] = v`), compound element updates, and keyed record reads. */
import { OBJECT_PROTOTYPE_MEMBERS } from "../../shape-inference/domain.js";
import * as ts from "../../ts7/adapter.js";
import {
lowerOptionalIndex,
@@ -1319,6 +1320,21 @@ export function lowerRecordKeyRead(
};
return lowerer.maybeNarrow(get, expr);
}
// A literal key the shape does not declare, on a shape without an index
// signature, names no own property of any of its values: the read
// answers undefined, as Node's does. The checker's key check cannot vouch
// for it here, since the receiver can be typed from a package's
// JavaScript while the checker read its declarations. Object.prototype
// members keep the keyed read below.
if (shape.indexValue === undefined && !OBJECT_PROTOTYPE_MEMBERS.has(litKey)) {
return {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: obj, loc }],
result: dynUndefinedExpr(loc),
type: DYN,
loc,
};
}
}
let key =
litKey !== null
@@ -1489,7 +1505,7 @@ function lowerRecordPropertyKey(lowerer: Lowerer, key: IrExpr, node: ts.Expressi
* exactly the key JS derives; negative/computed keys stay runtime
* expressions and canonicalize through ensureString instead). Null for
* everything else. */
function recordKeyLiteralText(node: ts.Expression): string | null {
export function recordKeyLiteralText(node: ts.Expression): string | null {
if (ts.isStringLiteral(node)) return node.text;
if (ts.isNumericLiteral(node)) return String(Number(node.text));
return null;
@@ -96,7 +96,8 @@ import {
lowerUnionKeyedRead,
} from "./expressions/property-access.js";
import { lowerNullishPair } from "./expressions/binary.js";
import { lowerElementCompoundParts } from "./expressions/element-access.js";
import { lowerElementCompoundParts, recordKeyLiteralText } from "./expressions/element-access.js";
import { OBJECT_PROTOTYPE_MEMBERS } from "../shape-inference/domain.js";
export {
DEFERRED_READ_MESSAGE,
abstractPropertyDeclOf,
@@ -1964,6 +1965,20 @@ export function lowerAbsenceProbe(lowerer: Lowerer, node: ts.Expression): IrExpr
if (shape && !shape.tuple && shape.indexValue && shape.indexValue.kind !== "dyn") {
return lowerer.lowerRecordKeyRead(expr, receiverT.shapeId, shape, true);
}
// A literal key the shape does not declare is absent from every value
// (lowerRecordKeyRead answers undefined), whatever type the checker gave
// the read.
const literal = recordKeyLiteralText(expr.argumentExpression);
if (
shape &&
!shape.tuple &&
!shape.indexValue &&
literal !== null &&
!OBJECT_PROTOTYPE_MEMBERS.has(literal) &&
!shape.fields.some((field) => field.name === literal)
) {
return lowerer.lowerRecordKeyRead(expr, receiverT.shapeId, shape, true);
}
return null;
}
// `m[2]` on a JavaScript `RegExpExecArray | null` that a previous
@@ -1318,6 +1318,7 @@ function jsDeclarationExpandsObject(
inferred !== null &&
jsOpenObjectType(decl, inferred, lowerer.shapes, lowerer.unions).kind === "dyn") ||
arms.some((arm) => arm.kind === "record" && jsObjectBindingExpands(lowerer, decl, arm)) ||
arms.some((arm) => arm.kind === "record" && jsObjectLiteralFilledLater(lowerer, decl, arm)) ||
(isJsSourceFile(ts.getSourceFileOfNode(decl)) &&
!decl.type &&
!hasJsTypeAnnotation(decl) &&
@@ -6553,6 +6554,41 @@ function lowerVariableInitializer(
}
}
/** A JS object literal that leaves out a required field of the binding's
* inferred record: tsc types `var p = {}; p.id = id;` by the members later
* assignments add, so the object only gains them after its initializer, and
* until then it is not a value of that record. Keep it a checked-dynamic
* object, as an object that gains keys outside the record is. */
function jsObjectLiteralFilledLater(
lowerer: Lowerer,
decl: ts.VariableDeclaration,
type: IrType & { kind: "record" },
): boolean {
if (decl.type || !decl.initializer || !isJsSourceFile(ts.getSourceFileOfNode(decl))) return false;
let initializer = decl.initializer;
while (ts.isParenthesizedExpression(initializer)) initializer = initializer.expression;
if (!ts.isObjectLiteralExpression(initializer) || hasJsTypeAnnotation(decl)) return false;
const shape = lowerer.shapes.get(type.shapeId);
if (!shape || shape.tuple) return false;
const given = new Set<string>();
for (const property of initializer.properties.nodes) {
if (ts.isSpreadAssignment(property)) return false;
const name = property.name;
if (name === undefined) return false;
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name))
given.add(name.text);
else return false;
}
// A field whose type holds undefined reads as it would before the write.
const holdsUndefined = (t: IrType): boolean =>
t.kind === "dyn" ||
t.kind === "undefinedT" ||
(t.kind === "union" && lowerer.armTag(t.unionId, UNDEFINED_T) >= 0);
return shape.fields.some(
(field) => !given.has(field.name) && !field.name.startsWith("%") && !holdsUndefined(field.type),
);
}
/** A JS object literal's inferred fields do not close its later storage.
* Keep the original native object when writes add keys or delete fields;
* converting it to a fixed record would discard that behavior. */
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/var-object-filled-later.js"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/fixtures/npm/cases/types-keyed-construction/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/fixtures/npm/cases/types-undeclared-quoted-key/main.ts"
],
"diags": []
}
+48
View File
@@ -0,0 +1,48 @@
// A function-scoped `var` initialized with an object literal that later
// assignments fill in: tsc types the binding by the members those
// assignments add, but the object only gains them after its initializer.
"use strict";
function load(id) {
var p = {};
console.log(p.count, "count" in p, Object.keys(p).length);
p.id = id;
p.count = id * 2;
p.label = "item-" + id;
return p;
}
const p = load(7);
console.log(p.id + p.count, p.label.toUpperCase(), JSON.stringify(p), Object.keys(p).join(","));
function partial(name) {
var entry = { name: name };
entry.size = name.length;
entry.tags = [name, name + "!"];
return entry;
}
const e = partial("abc");
console.log(e.size * 10, e.tags.join("|"), JSON.stringify(e));
function nested(depth) {
var node = {};
node.depth = depth;
node.child = depth > 0 ? nested(depth - 1) : null;
return node;
}
console.log(JSON.stringify(nested(2)));
function rows(n) {
var out = [];
for (var i = 0; i < n; i++) {
var row = {};
row.index = i;
row.square = i * i;
out.push(row);
}
return out;
}
console.log(rows(4).map(function (r) { return r.index + ":" + r.square; }).join(" "));
var top = {};
top.ready = true;
top.total = rows(3).reduce(function (s, r) { return s + r.square; }, 0);
console.log(top.ready, top.total, JSON.stringify(top));
@@ -0,0 +1,12 @@
// A plain-JavaScript package typed by an @types package builds its result
// key by key (`var p = {}; p.id = id; ...`): the object crosses the
// declaration boundary with every field, including the declared fields
// whose keys are not identifiers.
import { loadProfile, type Profile } from "profilekit";
const p: Profile = loadProfile(7);
console.log(p["a;b"].toUpperCase(), p["a;b"].length);
console.log(p["my-key"], p["my-key"].length, p["x y"].length);
console.log(p.id, p.count, JSON.stringify(p));
const keys: string[] = Object.keys(p);
console.log(keys.join("|"));
@@ -0,0 +1,8 @@
export interface Profile {
id: number;
"a;b": string;
"my-key": string;
"x y": string;
count: number;
}
export declare function loadProfile(id: number): Profile;
@@ -0,0 +1 @@
{ "name": "@types/profilekit", "version": "1.0.0", "types": "index.d.ts" }
@@ -0,0 +1,12 @@
"use strict";
// Plain JavaScript: the profile is built key by key.
function loadProfile(id) {
var p = {};
p.id = id;
p["a;b"] = "semi" + id;
p["my-key"] = "dash";
p["x y"] = "space";
p.count = id * 2;
return p;
}
exports.loadProfile = loadProfile;
@@ -0,0 +1 @@
{ "name": "profilekit", "version": "1.0.0", "main": "index.js" }
@@ -0,0 +1 @@
{ "name": "types-keyed-construction-case", "private": true, "type": "module" }
@@ -0,0 +1,20 @@
// A package typed from its JavaScript returns profiles without a key its
// declarations promise ("a;b"). A literal-key read of that key answers
// undefined, as Node's does; it never reads another field.
import { loadProfiles } from "hostilekit";
const profiles = loadProfiles();
for (const p of profiles) {
console.log(p.id, p["a;b"] === undefined, typeof p["a;b"], "a;b" in p);
try {
console.log(p["a;b"].toUpperCase());
} catch (e) {
console.log("upper threw", e instanceof TypeError);
}
try {
console.log(p["a;b"].length);
} catch (e) {
console.log("length threw", e instanceof TypeError);
}
console.log(p.id.toFixed(1), JSON.stringify(p));
}
@@ -0,0 +1,2 @@
export type Profile = { id: number; "a;b": string };
export declare function loadProfiles(): Profile[];
@@ -0,0 +1,5 @@
"use strict";
function loadProfiles() {
return [{ id: 1 }];
}
exports.loadProfiles = loadProfiles;
@@ -0,0 +1 @@
{ "name": "hostilekit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
@@ -0,0 +1 @@
{ "name": "types-undeclared-quoted-key-case", "private": true, "type": "module" }
@@ -0,0 +1 @@
{ "plain": 3400 }