From d1963dc8a51de56795fa47014854a2c5eabda227 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 26 Oct 2026 11:12:57 -0500 Subject: [PATCH] Test a scalar optional left of `??` in JavaScript on the typed path In JavaScript, `counts.get(key) ?? 0` on a Map the compiler types as Map is typed `any` by the checker, so `??` was lowered on checked-dynamic values: the `number | undefined` read converted to a dynamic value, which allocated a heap union for every read, and the test ran on that value. When the left of such a `??` is a union of one number, boolean or string arm with undefined or null, and the default has the same type, the test and the default now run on the typed path, where the read needs no heap union, and only the result converts to a dynamic value. Other defaults keep the dynamic path. New corpus program: untyped-nullish-scalar-defaults.mjs. --- .../frontend/lowering/expressions/binary.ts | 14 ++++++ .../untyped-nullish-scalar-defaults.mjs.json | 6 +++ .../test/untyped-nullish-scalar.test.ts | 46 +++++++++++++++++++ .../untyped-nullish-scalar-defaults.mjs | 46 +++++++++++++++++++ .../untyped-nullish-scalar-defaults.mjs.json | 1 + 5 files changed, 113 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/untyped-nullish-scalar-defaults.mjs.json create mode 100644 packages/compiler/test/untyped-nullish-scalar.test.ts create mode 100644 tests/corpus/untyped-nullish-scalar-defaults.mjs create mode 100644 tests/harness/corpus-costs/untyped-nullish-scalar-defaults.mjs.json diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index 0d24561de..39b19c7e2 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -388,6 +388,20 @@ export function lowerNullishPair( lowerer.runtimeOptionalArithmeticTypes.set(expr, type); } if (type.kind === "dyn" && lowerer.dynConvertible(left.type)) { + // `counts.get(k) ?? 0` in JavaScript: a scalar optional left and a + // default of its value's type give a scalar, so the test and the default + // run on the typed path and only the result converts. Converting the + // optional left itself would allocate a union for every read. + if (rest.length === 1 && primitive(rest[0]!) && rightType && typeEquals(rightType, rest[0]!)) { + const typed: IrExpr = { + kind: "nullish", + left, + right: lowerer.lowerExprExpecting(expr.right, rest[0]!), + type: rest[0]!, + loc, + }; + return lowerer.coerceToExpected(typed, DYN); + } return { kind: "nullish", left: lowerer.coerceToExpected(left, DYN), diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/untyped-nullish-scalar-defaults.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/untyped-nullish-scalar-defaults.mjs.json new file mode 100644 index 000000000..2bce0e918 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/untyped-nullish-scalar-defaults.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/untyped-nullish-scalar-defaults.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/untyped-nullish-scalar.test.ts b/packages/compiler/test/untyped-nullish-scalar.test.ts new file mode 100644 index 000000000..c9e496b88 --- /dev/null +++ b/packages/compiler/test/untyped-nullish-scalar.test.ts @@ -0,0 +1,46 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule, validateModule } from "../src/index.js"; + +test("`??` in JavaScript tests a scalar optional left on the typed path when the default has its type", async () => { + const dir = mkdtempSync(join(tmpdir(), "scriptc-untyped-nullish-")); + try { + const entry = join(dir, "main.mjs"); + writeFileSync( + entry, + [ + "const counts = new Map();", + 'for (const w of ["a", "b", "a"]) counts.set(w, (counts.get(w) ?? 0) + 1);', + 'console.log(counts.get("a") ?? 0, counts.get("z") ?? "none");', + ].join("\n"), + ); + const output = join(dir, "main.ir.json"); + const lowered = await compile(entry, { outputKind: "ir", outDir: dir, outPath: output }); + if (!lowered.ok) throw new Error(JSON.stringify(lowered.diagnostics)); + const module = deserializeModule(readFileSync(output, "utf8")); + expect(validateModule(module)).toEqual([]); + const nullish: { type: string; left: string }[] = []; + const visit = (node: unknown, parent: string | null): void => { + if (node === null || typeof node !== "object") return; + if (Array.isArray(node)) return node.forEach((child) => visit(child, parent)); + const n = node as { kind?: string; type?: { kind: string }; left?: { kind: string } }; + if (n.kind === "nullish") + nullish.push({ type: `${parent}>${n.type!.kind}`, left: n.left!.kind }); + for (const [key, value] of Object.entries(node)) + if (key !== "loc") visit(value, n.kind ?? parent); + }; + visit(module.functions, null); + // The loop's `?? 0`, whose result is an untyped value, reads the Map's + // number on the typed path and converts only the result. (The first + // console.log argument is a number already.) `?? "none"` takes another + // type, so its left converts first. + expect( + nullish.filter((n) => n.type === "dynFrom>f64" && n.left === "mapIntrinsic"), + ).toHaveLength(1); + expect(nullish.filter((n) => n.type.endsWith(">dyn") && n.left === "dynFrom")).toHaveLength(1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/corpus/untyped-nullish-scalar-defaults.mjs b/tests/corpus/untyped-nullish-scalar-defaults.mjs new file mode 100644 index 000000000..5057e369e --- /dev/null +++ b/tests/corpus/untyped-nullish-scalar-defaults.mjs @@ -0,0 +1,46 @@ +// `??` in untyped JavaScript where the left is an optional number, boolean or +// string and the default has the same type: Map reads, find(), a function that +// may return undefined, and defaults of another type, null or an object. +const words = ["alpha", "beta", "gamma", "beta", "delta", "beta", "alpha"]; +const counts = new Map(); +for (const word of words) counts.set(word, (counts.get(word) ?? 0) + 1); +for (const [word, n] of counts) console.log(word, n); + +const odd = new Map(); +odd.set("nan", NaN); +odd.set("zero", -0); +odd.set("one", 1); +for (const key of ["nan", "zero", "one", "missing"]) { + const value = odd.get(key) ?? 7; + console.log(key, value, Object.is(value, -0), Number.isNaN(value), typeof value); +} + +const flags = new Map(); +flags.set("on", true); +flags.set("off", false); +for (const key of ["on", "off", "missing"]) console.log(key, flags.get(key) ?? true); + +const names = new Map(); +names.set("a", ""); +names.set("b", "bee"); +for (const key of ["a", "b", "c"]) console.log(JSON.stringify(names.get(key) ?? "none")); + +const values = [3, 8, 13, 21]; +console.log(values.find((v) => v > 10) ?? -1, values.find((v) => v > 100) ?? -1); + +function lookup(table, key) { + return key in table ? table[key] : undefined; +} +const table = { x: 1.5, y: 0 }; +console.log(lookup(table, "x") ?? 0, lookup(table, "y") ?? 9, lookup(table, "z") ?? 9); + +// Defaults of another type keep the left's value or take the default as is. +console.log(counts.get("beta") ?? "none", counts.get("omega") ?? "none"); +console.log(counts.get("omega") ?? null, counts.get("beta") ?? null); +const fallback = { n: 0 }; +console.log(counts.get("omega") ?? fallback, counts.get("alpha") ?? fallback); + +// The result is an ordinary value in later arithmetic and comparisons. +let total = 0; +for (const word of ["alpha", "beta", "omega"]) total += (counts.get(word) ?? 0) * 10; +console.log(total, (counts.get("omega") ?? 0) === 0, (odd.get("nan") ?? 0) === (odd.get("nan") ?? 0)); diff --git a/tests/harness/corpus-costs/untyped-nullish-scalar-defaults.mjs.json b/tests/harness/corpus-costs/untyped-nullish-scalar-defaults.mjs.json new file mode 100644 index 000000000..e822c3bda --- /dev/null +++ b/tests/harness/corpus-costs/untyped-nullish-scalar-defaults.mjs.json @@ -0,0 +1 @@ +{ "plain": 3711, "san": 5463 }