Fix recursive JSON unions and primitive constructors as array callbacks (#1782)

This commit is contained in:
Chris Tate
2026-10-22 04:01:35 -05:00
committed by GitHub
14 changed files with 316 additions and 9 deletions
@@ -6,7 +6,9 @@ import { varRef } from "../../../ir/build.js";
import { typeKey } from "../../type-mapper.js";
import {
BOOL,
DYN,
F64,
STRING,
type IrExpr,
type IrType,
type SrcLoc,
@@ -69,6 +71,20 @@ export function lowerArrayCallback(
if (valueT.kind === "union") lowerer.requireTruthyUnion(valueT.unionId, argNode);
return { fnArg: truthyFilterCallback(lowerer, valueT, locOf(argNode)), arity: 1 };
}
// The ambient `String` (`values.map(String)`): String(value) converts its
// first argument as a direct call does, whatever the element type; the
// constructor's own value form takes a string.
if (
ts.isIdentifier(argNode) &&
argNode.text === "String" &&
!isJsSourceFile(ts.getSourceFileOfNode(argNode)) &&
lowerer.isStdlibSymbol(lowerer.resolveValueSymbol(argNode) ?? undefined)
) {
const valueT = lead[0];
if (valueT === undefined || valueT.kind === "void")
lowerer.badType(argNode, lowerer.typeOf(argNode));
return { fnArg: stringConversionCallback(lowerer, valueT, argNode), arity: 1 };
}
const full = [...lead, F64, arrT];
// A DYN-receiver HOF's callback (`parsed.flatMap((value) => ...)`):
// the contextual signature types the unannotated param `any` (the
@@ -274,6 +290,49 @@ function runtimeOptionalPairVariant(lowerer: Lowerer, expected: IrType, mapped:
return lowerer.isRuntimeOptionalRecordVariant(strip(expected), strip(mapped));
}
/** `String` as a one-parameter callback over `valueT`: the conversion a
* direct `String(value)` call lowers to (the runtime's String() for unknown
* values and unions holding a symbol, ToString otherwise), interned per
* value type. */
function stringConversionCallback(
lowerer: Lowerer,
valueT: IrType,
node: ts.Expression,
): IrExpr & { type: IrType & { kind: "func" } } {
const loc = locOf(node);
const fnT: IrType & { kind: "func" } = { kind: "func", params: [valueT], ret: STRING };
const key = `mapString:${typeKey(valueT)}`;
let name = lowerer.arrHofHelpers.get(key);
if (!name) {
name = `%arr.mapString.${lowerer.arrHofHelpers.size}`;
lowerer.arrHofHelpers.set(key, name);
const value = varRef("v.0", valueT, loc);
const runtimeString =
valueT.kind === "dyn" ||
(valueT.kind === "union" &&
lowerer.unions.get(valueT.unionId)?.arms.some((arm) => arm.kind === "symbol") === true &&
lowerer.dynConvertible(valueT));
const converted: IrExpr = runtimeString
? {
kind: "libCall",
fn: "dyn.stringConstructor",
args: [lowerer.coerceToExpected(value, DYN)],
type: STRING,
loc,
}
: lowerer.ensureString(value, node);
lowerer.liftedFns.push({
name,
params: [{ localId: "v.0", name: "v", type: valueT }],
returnType: STRING,
locals: [{ id: "v.0", name: "v", type: valueT, mutable: false }],
body: [{ kind: "return", value: converted, loc }],
loc,
});
}
return { kind: "closure", fnName: name, captures: [], type: fnT, loc };
}
/** A native closure for the ambient Boolean callback. Reifying the
* JavaScript constructor itself is unnecessary; Array#filter only observes
* its one-argument ToBoolean result. */
@@ -184,6 +184,21 @@ export function lowerArrayLiteral(
if (arms.length === 1) mapped = arms[0]!;
}
}
// A NON-EMPTY literal whose own type is an array, under a union with
// exactly one array arm and no tuple arm (`[1, { n: true }]` in a
// `Json[]` arm of a recursive `Json`): the checker types each element
// from the arm, so its object literals lower as the arm's elements,
// and the literal's own element type (`number | { n: boolean }`)
// cannot hold them. Build it as the arm; its value only flows into the
// union slot.
if (ctxUnion && expr.elements.nodes.length > 0 && mapped?.kind === "array") {
const def = lowerer.unions.get(ctxUnion.unionId);
const arrayArms = def?.arms.filter((a) => a.kind === "array") ?? [];
const tupleArms =
def?.arms.filter((a) => a.kind === "record" && !!lowerer.shapes.get(a.shapeId)?.tuple) ??
[];
if (arrayArms.length === 1 && tupleArms.length === 0) mapped = arrayArms[0]!;
}
}
// A Date's toJSON() element keeps its null in a literal with no declared
// destination: the lib types say string.
@@ -11,6 +11,7 @@ import {
DYN,
F64,
type IrExpr,
type IrLocal,
type IrType,
STRING,
type SrcLoc,
@@ -226,7 +227,10 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc:
if (use?.kind === "element") {
const key = runtimeOptionalElementKey(use.access.argumentExpression);
if (key === null) {
return varRef(local.id, local.type, loc);
return (
runtimeOptionalKeyedRecord(lowerer, expr, local, loc) ??
varRef(local.id, local.type, loc)
);
}
return runtimeOptionalReceiverRead(lowerer, expr, local, key, loc);
}
@@ -938,10 +942,43 @@ function moduleFileName(lowerer: Lowerer, sf: ts.SourceFile): string {
: sf.fileName;
}
/** Object.keys/Object.entries require an object receiver, but their first
* argument is not syntactically a member receiver. Recognize that one
* standard-library position so a runtime-optional loop element can be
* checked against the checker-narrowed record arm before builtin lowering. */
/** A computed-key access (`value[key]` after `typeof value === "object"`)
* of a runtime-optional binding the checker narrowed to one record arm of a
* wider union. The binding re-tags to that record or undefined, so the keyed
* read or write evaluates its operands and then throws Node's TypeError for
* an undefined receiver. The other arms, which the checker excluded, fail
* the checked re-tag. Null when the binding stores only the record and
* undefined, or the narrowed record is not one of its arms. */
function runtimeOptionalKeyedRecord(
lowerer: Lowerer,
expr: ts.Identifier,
local: IrLocal,
loc: SrcLoc,
): IrExpr | null {
if (local.type.kind !== "union") return null;
const narrowed = lowerer.mapTypeOf(lowerer.typeOf(expr));
if (narrowed?.kind !== "record") return null;
const fromId = local.type.unionId;
const arms = lowerer.unions.get(fromId)?.arms;
const recordTag = lowerer.armTag(fromId, narrowed);
const undefTag = lowerer.armTag(fromId, UNDEFINED_T);
if (!arms || arms.length <= 2 || recordTag < 0 || undefTag < 0) return null;
const target = lowerer.withUndefinedArm(narrowed);
if (target.kind !== "union") return null;
const trappable = new Set<number>();
for (let tag = 0; tag < arms.length; tag++) {
if (tag !== recordTag && tag !== undefTag) trappable.add(tag);
}
const helper = lowerer.unionRetagHelper(fromId, target.unionId, loc, trappable);
if (!helper) return null;
return lowerer.checkedNarrowCall(helper, varRef(local.id, local.type, loc), target, loc);
}
/** Object.keys, Object.values and Object.entries require an object
* receiver, but their first argument is not syntactically a member
* receiver. Recognize that one standard-library position so a
* runtime-optional loop element can be checked against the checker-narrowed
* record arm before builtin lowering. */
function runtimeOptionalObjectWalkerArg(lowerer: Lowerer, expr: ts.Expression): boolean {
let arg = expr;
while (
@@ -962,7 +999,9 @@ function runtimeOptionalObjectWalkerArg(lowerer: Lowerer, expr: ts.Expression):
return false;
const access = call.expression;
return (
(access.name.text === "keys" || access.name.text === "entries") &&
(access.name.text === "keys" ||
access.name.text === "values" ||
access.name.text === "entries") &&
lowerer.isStdlibMember(access)
);
}
@@ -173,6 +173,7 @@ import {
stringWrapperToString,
symbolFieldInfo,
templateRawTextOf,
primitiveCtorClosure,
} from "./lower-exprs.js";
import { isSafeToDiscard } from "./expressions/evaluation-safety.js";
import { tryLowerExpression } from "./expressions/try-lower-expression.js";
@@ -4343,7 +4344,25 @@ export function lowerDynDispatchMethodCall(
}
}
}
// `String`, `Number` or `Boolean` as the callback of an array method
// (`values.map(String)` over an unknown array): the method calls it with
// each element, whatever its type, and cannot observe which function
// value it received, so it gets the constructor's form that converts any
// argument rather than the string-input form typed code stores.
const arrayCallbackReceiver =
ARRAY_CALLBACK_METHODS.has(method) &&
lowerer.checker.isArrayType(lowerer.typeOf(access.expression));
const args = call.arguments.nodes.map((arg, i) => {
const primitive =
i === 0 && arrayCallbackReceiver && ts.isIdentifier(arg)
? stdlibGlobalNameOf(lowerer, arg)
: null;
if (primitive === "String" || primitive === "Number" || primitive === "Boolean")
return lowerer.coerceInto(
arg,
primitiveCtorClosure(lowerer, primitive, locOf(arg), true),
DYN,
);
if (i === 0 && predicate) return lowerer.coerceInto(arg, predicate, DYN);
const undefinedArg = lowerStaticallyUndefinedArgument(lowerer, arg);
return undefinedArg
@@ -4361,6 +4380,21 @@ export function lowerDynDispatchMethodCall(
};
}
/** Array.prototype methods whose first argument is a callback they call
* with the elements. */
const ARRAY_CALLBACK_METHODS = new Set([
"map",
"filter",
"forEach",
"some",
"every",
"find",
"findIndex",
"findLast",
"findLastIndex",
"flatMap",
]);
/** Every String.prototype method name a typed or dyn string lowering
* answers. */
function isStringMethodName(name: string): boolean {
@@ -3057,8 +3057,10 @@ export function lowerFieldCompound(
* returns the zero-capture closure over it. Typed sources use the mapped
* `(value: string) => primitive` signature; a recognized JavaScript
* concise-arrow selector (`() => String`, the picocolors fallback) takes
* dyn so its ordinary coercion signature survives inference residue. The
* dyn bodies run exact ToString/ToNumber/ToBoolean, including object hooks.
* dyn so its ordinary coercion signature survives inference residue, and a
* boxed constructor value takes dyn too (coerceToExpected). The dyn bodies
* run what the constructors do for any argument: String() (a symbol's
* description form included), ToNumber and ToBoolean, with object hooks.
* Interning makes every reference under one ABI the SAME immortal closure,
* so `opt.type === String` compares like JS function identity. */
export function primitiveCtorClosure(
@@ -3081,7 +3083,7 @@ export function primitiveCtorClosure(
? name === "BigInt"
? { kind: "libCall", fn: "dyn.bigintConstructor", args: [v], type: BIGINT_T, loc }
: name === "String"
? { kind: "libCall", fn: "dyn.toStringCoerce", args: [v], type: STRING, loc }
? { kind: "libCall", fn: "dyn.stringConstructor", args: [v], type: STRING, loc }
: name === "Number"
? { kind: "libCall", fn: "dyn.numberConstructor", args: [v], type: F64, loc }
: { kind: "dynTest", test: "truthy", value: v, type: BOOL, loc }
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6826-map-primitive-constructors.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6827-recursive-union-nested-literals.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6828-recursive-union-object-walkers.ts"
],
"diags": []
}
@@ -0,0 +1,25 @@
// The String, Number and Boolean constructors passed as callbacks: over
// unknown arrays they convert any element, over typed arrays String converts
// each element as String(value) does, and the boxed values keep identity.
const values: unknown[] = [1, "b", true, null, undefined, Symbol("s"), [1, 2], { a: 1 }, 2n];
console.log(values.map(String).join("|"));
console.log(values.slice(0, 5).map(Number), values.map(Boolean));
function joinAll(...parts: unknown[]): string {
return parts.map(String).join(",");
}
console.log(joinAll(1, "a", false, null));
const numbers = [1, 2.5, -0, NaN, Infinity];
console.log(numbers.map(String));
const mixed: (number | string | boolean)[] = [1, "x", false];
console.log(mixed.map(String));
const symbols: (symbol | string)[] = [Symbol("q"), "r", Symbol()];
console.log(symbols.map(String));
console.log([10n, -3n].map(String));
console.log(["1", " 2 ", "x", ""].map(Number), ["", "0", "a"].map(Boolean));
const boxed: unknown = String;
const table: { [name: string]: unknown } = { s: String, n: Number, b: Boolean };
console.log(boxed === String, table.n === Number, table.b === String, typeof boxed);
console.log(values.filter(Boolean).length, values.some(Boolean), numbers.every(Boolean));
@@ -0,0 +1,29 @@
// A recursive JSON-like union with an array arm and an index-signature
// record arm: nested literals mix records into arrays and arrays into
// records at every depth.
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
function show(value: Json): string {
return JSON.stringify(value);
}
const config: Json = { list: [1, "two", null, { nested: [true, { deeper: [] }] }], flag: false };
console.log(show(config));
console.log(show({ list: [{ n: true }] }));
console.log(show([1, { k: null }, [{ a: [2] }]]));
console.log(show({ a: { b: [{ c: "x" }, 3] } }));
const rows: Json[] = [{ id: 1, tags: ["a", { t: 2 }] }, [null, { x: [] }]];
console.log(rows.map(show).join(" | "));
let value: Json = [];
value = { replaced: [1, { again: [true] }] };
console.log(show(value));
const doc: { [key: string]: Json } = { items: [{ name: "x", parts: [1, { p: "q" }] }] };
console.log(show(doc));
const items = doc["items"];
if (Array.isArray(items)) {
items.push({ added: [9] });
console.log(items.length, show(items));
}
@@ -0,0 +1,83 @@
// Recursive walkers over a JSON-like union: the array branch calls the
// walker for each element, and the record branch reads the narrowed record
// with Object.values, Object.keys and Object.entries.
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
function depth(value: Json): number {
if (Array.isArray(value)) return 1 + Math.max(0, ...value.map(depth));
if (value !== null && typeof value === "object") return 1 + Math.max(0, ...Object.values(value).map(depth));
return 0;
}
function count(value: Json): number {
let total = 1;
if (Array.isArray(value)) {
for (const item of value) total += count(item);
} else if (value !== null && typeof value === "object") {
for (const item of Object.values(value)) total += count(item);
}
return total;
}
function paths(value: Json, prefix: string): string[] {
if (Array.isArray(value)) return value.flatMap((item, index) => paths(item, `${prefix}[${index}]`));
if (value !== null && typeof value === "object") {
const out: string[] = [];
for (const key of Object.keys(value)) out.push(...paths(value[key]!, `${prefix}.${key}`));
for (const [key, item] of Object.entries(value)) if (item === null) out.push(`${prefix}.${key}=null`);
return out;
}
return [`${prefix}=${String(value)}`];
}
const doc: Json = JSON.parse('{"a":[1,{"b":[true,null]}],"c":{"d":"x"},"e":[]}');
console.log(depth(doc), count(doc));
console.log(paths(doc, "$").join(" "));
console.log(depth(3), count([[[]]]), paths([], "$"));
// A computed-key read or write in a branch that an array hole reaches throws
// Node's TypeError after the key (and, for a write, the value) evaluates.
type Bag = number | string | number[] | { [key: string]: number };
const keys: string[] = [];
function keyOf(k: string): string {
keys.push(k);
return k;
}
function isBag(value: Bag): value is { [key: string]: number } {
return !Array.isArray(value) && typeof value !== "string" && typeof value !== "number";
}
function bump(value: Bag, k: string): string {
if (isBag(value)) {
value[keyOf(k)] = (value[keyOf(k)] ?? 0) + 1;
return String(value[keyOf(k)]);
}
return "-";
}
function put(value: Bag, k: string): string {
if (isBag(value)) {
value[keyOf(k)] = keys.length;
return "put";
}
return "-";
}
function peek(value: Bag, k: string): string {
if (isBag(value)) return typeof value[keyOf(k)];
return "-";
}
const bags: Bag[] = [{ a: 1 }, "s", [1]];
bags.length = 4;
for (const which of ["bump", "put", "peek"]) {
const out: string[] = [];
for (let i = 0; i < bags.length; i++) {
try {
const k = "a" + which.length;
if (which === "bump") out.push(bump(bags[i], k));
else if (which === "put") out.push(put(bags[i], k));
else out.push(peek(bags[i], k));
} catch (e) {
out.push(e instanceof TypeError ? e.message : "other");
}
}
console.log(out.join(" | "));
}
console.log(keys.join(","), JSON.stringify(bags));
@@ -0,0 +1 @@
{ "plain": 3700, "san": 3500 }
@@ -0,0 +1 @@
{ "plain": 3500, "san": 3400 }
@@ -0,0 +1 @@
{ "plain": 4200, "san": 5800 }