Type untyped JavaScript Maps, Sets and arrays from their uses (#2089)

This commit is contained in:
Chris Tate
2026-10-24 17:45:47 -05:00
committed by GitHub
8 changed files with 766 additions and 2 deletions
+21
View File
@@ -70,3 +70,24 @@ interface PromiseConstructor {
reject: (reason: Error) => void;
};
}
/* Carriers of the types the lowerer gives untyped JavaScript collections
* whose uses prove them (js-collection-types.ts). Nothing reads them at run
* time. */
declare namespace __scriptcInferredCollections {
const map_string_string: Map<string, string>;
const map_string_number: Map<string, number>;
const map_string_boolean: Map<string, boolean>;
const map_number_string: Map<number, string>;
const map_number_number: Map<number, number>;
const map_number_boolean: Map<number, boolean>;
const map_boolean_string: Map<boolean, string>;
const map_boolean_number: Map<boolean, number>;
const map_boolean_boolean: Map<boolean, boolean>;
const set_string: Set<string>;
const set_number: Set<number>;
const set_boolean: Set<boolean>;
const array_string: string[];
const array_number: number[];
const array_boolean: boolean[];
}
@@ -0,0 +1,533 @@
/* Types of untyped JavaScript collections that their uses prove.
*
* In a JavaScript file, `const m = new Map()`, `new Set()` and `[]` type as
* Map<any, any>, Set<any> and any[] to the checker, so every use lowers to
* the checked-dynamic collection operations. When every reference of such a
* `const` is a direct use of the collection, the value never leaves the
* binding. If its keys, values or elements are then each one primitive type
* wherever the program writes and looks them up, the binding lowers as the
* typed Map<K, V>, Set<K> or T[], as a JSDoc @type would make it:
*
* - Map: `m.get(k)`, `m.has(k)`, `m.delete(k)`, `m.clear()`, `m.size`, and
* `m.set(k, v)` as a statement (its result is the map).
* - Set: `s.add(k)` as a statement, `s.has(k)`, `s.delete(k)`, `s.clear()`,
* `s.size`.
* - Array: `a.push(...)`, `a.pop()`, `a.shift()`, `a.at(i)`, `a.includes(v)`,
* `a.indexOf(v)`, `a.join(separator)`, `a.length`.
*
* Any other reference (passing, returning, storing, exporting, iterating,
* spreading, inspecting the collection, an optional call), a direct `eval`
* in the file, or any doubt about a type keeps the binding untyped.
*
* The types come from carrier declarations in scriptc-overrides.d.ts, which
* only the lowering program includes. */
import * as ts from "../ts7/adapter.js";
import { isJsSourceFile } from "../program.js";
import { overridesDtsPath } from "../dts-paths.js";
import type { Lowerer } from "./lowerer.js";
type Primitive = "string" | "number" | "boolean";
const PRIMITIVES: readonly Primitive[] = ["string", "number", "boolean"];
type Collection = "map" | "set" | "array";
interface Candidate {
readonly kind: Collection;
readonly declaration: ts.VariableDeclaration;
/** Every identifier that names the binding, its declaration's included. */
readonly names: ts.Identifier[];
/** Map keys or Set members written by set/add. */
readonly keyWrites: ts.Expression[];
/** Map values written by set. Array elements written by push. */
readonly valueWrites: ts.Expression[];
/** Arguments compared with keys or elements: get/has/delete, includes/indexOf. */
readonly lookups: ts.Expression[];
/** Array indexes passed to at(). */
readonly indexes: ts.Expression[];
/** Separators passed to join(). */
readonly separators: ts.Expression[];
key: Primitive | undefined;
value: Primitive | undefined;
}
const MAP_LOOKUPS = new Set(["get", "has", "delete"]);
const SET_LOOKUPS = new Set(["has", "delete"]);
const ARRAY_READS = new Set(["pop", "shift"]);
const ARRAY_SEARCHES = new Set(["includes", "indexOf"]);
/** The expression lowering refuses deeper nesting (SC1090). */
const MAX_DEPTH = 200;
/** The inferred type of the binding a `new Map()` or `new Set()` initializes,
* which the construction adopts. */
export function initializedCollectionType(
lowerer: Lowerer,
expr: ts.NewExpression,
): ts.Type | null {
const declaration = expr.parent;
return declaration !== undefined &&
ts.isVariableDeclaration(declaration) &&
declaration.initializer === expr &&
ts.isIdentifier(declaration.name)
? inferredCollectionType(lowerer, declaration.name)
: null;
}
/** The inferred collection type of an identifier that names a proven
* binding, or null. Computed for every JavaScript file of the program on
* first use. */
export function inferredCollectionType(lowerer: Lowerer, node: ts.Identifier): ts.Type | null {
const types = (lowerer.inferredCollectionTypes ??= inferProgram(lowerer));
return types.size === 0 ? null : (types.get(node) ?? null);
}
function inferProgram(lowerer: Lowerer): Map<ts.Node, ts.Type> {
const out = new Map<ts.Node, ts.Type>();
const carriers = carrierTypes(lowerer);
if (carriers === null) return out;
for (const file of lowerer.program.getSourceFiles()) {
if (file.isDeclarationFile || !isJsSourceFile(file)) continue;
for (const candidate of inferFile(lowerer, file)) {
const name =
candidate.kind === "map"
? `map_${candidate.key}_${candidate.value}`
: candidate.kind === "set"
? `set_${candidate.key}`
: `array_${candidate.value}`;
const type = carriers.get(name);
if (type === undefined) continue;
for (const id of candidate.names) out.set(id, type);
}
}
return out;
}
/** The carrier declarations' types, by name (`map_string_number`,
* `set_number`, `array_string`, ...), or null when the lowering program does
* not include them. */
function carrierTypes(lowerer: Lowerer): Map<string, ts.Type> | null {
const file = lowerer.program.getSourceFile(overridesDtsPath());
if (file === undefined) return null;
const out = new Map<string, ts.Type>();
for (const statement of file.statements.nodes) {
if (
!ts.isModuleDeclaration(statement) ||
!ts.isIdentifier(statement.name) ||
statement.name.text !== "__scriptcInferredCollections" ||
statement.body === undefined ||
!ts.isModuleBlock(statement.body)
)
continue;
for (const member of statement.body.statements.nodes) {
if (!ts.isVariableStatement(member)) continue;
for (const declaration of member.declarationList.declarations.nodes)
if (ts.isIdentifier(declaration.name))
out.set(declaration.name.text, lowerer.checker.getTypeAtLocation(declaration.name));
}
}
return out.size > 0 ? out : null;
}
function inferFile(lowerer: Lowerer, file: ts.SourceFile): Candidate[] {
const candidates = new Map<ts.Symbol, Candidate>();
let directEval = false;
const declarations = (node: ts.Node): void => {
if (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === "eval"
)
directEval = true;
if (ts.isVariableDeclaration(node)) {
const kind = candidateKind(lowerer, node);
const symbol = kind !== null ? lowerer.checker.getSymbolAtLocation(node.name) : undefined;
if (kind !== null && symbol !== undefined)
candidates.set(symbol, {
kind,
declaration: node,
names: [node.name as ts.Identifier],
keyWrites: [],
valueWrites: [],
lookups: [],
indexes: [],
separators: [],
key: undefined,
value: undefined,
});
}
};
ts.walkPreorder(file, declarations);
if (directEval || candidates.size === 0) return [];
const names = new Set(
[...candidates.values()].map((c) => (c.declaration.name as ts.Identifier).text),
);
const refused = new Set<ts.Symbol>();
const references = (node: ts.Node): void => {
if (ts.isIdentifier(node) && names.has(node.text)) {
const symbol = referencedSymbol(lowerer, node);
const candidate = symbol !== undefined ? candidates.get(symbol) : undefined;
if (symbol !== undefined && candidate !== undefined && node !== candidate.declaration.name) {
if (recordUse(candidate, node)) candidate.names.push(node);
else refused.add(symbol);
}
}
};
ts.walkPreorder(file, references);
for (const symbol of refused) candidates.delete(symbol);
const proven = [...candidates.values()].filter(
(c) => c.valueWrites.length > 0 || c.keyWrites.length > 0,
);
solve(lowerer, proven);
return proven.filter((c) =>
c.kind === "map" ? c.key && c.value : c.kind === "set" ? c.key : c.value,
);
}
/** The collection a `const` declaration starts empty, when the checker
* types it as an untyped Map, Set or array. */
function candidateKind(lowerer: Lowerer, declaration: ts.VariableDeclaration): Collection | null {
if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) return null;
const list = declaration.parent;
if (!ts.isVariableDeclarationList(list) || (list.flags & ts.NodeFlags.Const) === 0) return null;
const statement = list.parent;
if (
ts.isVariableStatement(statement) &&
ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
)
return null;
const init = declaration.initializer;
let kind: Collection | null = null;
if (ts.isArrayLiteralExpression(init) && init.elements.nodes.length === 0) kind = "array";
else if (
ts.isNewExpression(init) &&
init.typeArguments === undefined &&
(init.arguments?.nodes.length ?? 0) === 0
) {
if (lowerer.isStdlibGlobal(init.expression, "Map")) kind = "map";
else if (lowerer.isStdlibGlobal(init.expression, "Set")) kind = "set";
}
if (kind === null) return null;
// A JSDoc type, or anything else the checker already knows, stands.
const type = lowerer.checker.getTypeAtLocation(declaration.name);
const args =
kind === "array"
? lowerer.checker.isArrayType(type)
? lowerer.checker.getTypeArguments(type as ts.TypeReference)
: []
: lowerer.checker.getTypeArguments(type as ts.TypeReference);
const arity = kind === "map" ? 2 : 1;
if (args.length !== arity || !args.every((a) => (a.flags & ts.TypeFlags.Any) !== 0)) return null;
return kind;
}
function referencedSymbol(lowerer: Lowerer, id: ts.Identifier): ts.Symbol | undefined {
const parent = id.parent;
if (parent !== undefined && ts.isShorthandPropertyAssignment(parent) && parent.name === id)
return lowerer.checker.getShorthandAssignmentValueSymbol(parent);
const symbol = lowerer.checker.getSymbolAtLocation(id);
// `export { m }` names the export, an alias of the local binding.
if (
symbol !== undefined &&
parent !== undefined &&
ts.isExportSpecifier(parent) &&
parent.propertyName === undefined &&
(symbol.flags & ts.SymbolFlags.Alias) !== 0
)
return lowerer.checker.getAliasedSymbol(symbol);
return symbol;
}
/** Records a reference's use, or answers false when the reference is not
* one of the collection uses this inference proves. */
function recordUse(candidate: Candidate, id: ts.Identifier): boolean {
const access = id.parent;
if (
access === undefined ||
!ts.isPropertyAccessExpression(access) ||
access.expression !== id ||
access.questionDotToken !== undefined
)
return false;
const name = access.name.text;
const call = access.parent;
if (call !== undefined && ts.isCallExpression(call) && call.expression === access) {
if (call.questionDotToken !== undefined) return false;
const args = call.arguments.nodes;
if (args.some(ts.isSpreadElement)) return false;
switch (candidate.kind) {
case "map":
if (MAP_LOOKUPS.has(name) && args.length === 1)
return (candidate.lookups.push(args[0]!), true);
if (name === "clear") return args.length === 0;
if (name === "set" && args.length === 2 && isStatement(call)) {
candidate.keyWrites.push(args[0]!);
candidate.valueWrites.push(args[1]!);
return true;
}
return false;
case "set":
if (SET_LOOKUPS.has(name) && args.length === 1)
return (candidate.lookups.push(args[0]!), true);
if (name === "clear") return args.length === 0;
if (name === "add" && args.length === 1 && isStatement(call))
return (candidate.keyWrites.push(args[0]!), true);
return false;
case "array":
if (name === "push") return (candidate.valueWrites.push(...args), true);
if (ARRAY_READS.has(name)) return args.length === 0;
if (name === "at" && args.length === 1) return (candidate.indexes.push(args[0]!), true);
if (ARRAY_SEARCHES.has(name) && args.length === 1)
return (candidate.lookups.push(args[0]!), true);
if (name === "join" && args.length <= 1) return (candidate.separators.push(...args), true);
return false;
}
}
return (candidate.kind === "array" ? name === "length" : name === "size") && isPlainRead(access);
}
function isStatement(call: ts.CallExpression): boolean {
let node: ts.Node = call;
while (node.parent !== undefined && ts.isParenthesizedExpression(node.parent)) node = node.parent;
return node.parent !== undefined && ts.isExpressionStatement(node.parent);
}
/** A read that neither writes nor deletes the property. */
function isPlainRead(access: ts.PropertyAccessExpression): boolean {
let node: ts.Node = access;
while (node.parent !== undefined && ts.isParenthesizedExpression(node.parent)) node = node.parent;
const parent = node.parent;
if (parent === undefined) return false;
if (
ts.isBinaryExpression(parent) &&
parent.left === node &&
isAssignment(parent.operatorToken.kind)
)
return false;
if (
(ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) &&
(parent.operator === ts.SyntaxKind.PlusPlusToken ||
parent.operator === ts.SyntaxKind.MinusMinusToken)
)
return false;
return !ts.isDeleteExpression(parent);
}
function isAssignment(kind: ts.SyntaxKind): boolean {
return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment;
}
/** Decides each candidate's key and value types: a fixed point in which a
* decided candidate's reads type the expressions built from them. */
function solve(lowerer: Lowerer, candidates: Candidate[]): void {
const bySymbol = new Map<ts.Symbol, Candidate>();
for (const c of candidates) {
const symbol = lowerer.checker.getSymbolAtLocation(c.declaration.name);
if (symbol) bySymbol.set(symbol, c);
}
for (let changed = true; changed;) {
changed = false;
for (const c of candidates) {
if (c.kind !== "array" && c.key === undefined) {
const key = uniform(lowerer, bySymbol, c.keyWrites);
if (key !== null) {
c.key = key;
changed = true;
}
}
if (c.kind !== "set" && c.value === undefined) {
// Each candidate type is assumed for this collection's own reads.
const accepted = PRIMITIVES.filter((assumed) => {
c.value = assumed;
const found = uniform(lowerer, bySymbol, c.valueWrites);
c.value = undefined;
return found === assumed;
});
if (accepted.length === 1) {
c.value = accepted[0];
changed = true;
}
}
}
}
for (const c of candidates) {
const element = c.kind === "array" ? c.value : c.key;
// Lookups compare with the stored type; an index is a number.
const lookupsAgree =
element !== undefined &&
c.lookups.every((e) => primitiveOf(lowerer, bySymbol, e, new Set()) === element) &&
c.indexes.every((e) => primitiveOf(lowerer, bySymbol, e, new Set()) === "number") &&
c.separators.every((e) => primitiveOf(lowerer, bySymbol, e, new Set()) === "string");
if (!lookupsAgree) {
c.key = undefined;
c.value = undefined;
}
}
}
/** The one primitive type of every expression, or null. */
function uniform(
lowerer: Lowerer,
bySymbol: Map<ts.Symbol, Candidate>,
exprs: readonly ts.Expression[],
): Primitive | null {
let found: Primitive | null = null;
for (const e of exprs) {
const p = primitiveOf(lowerer, bySymbol, e, new Set());
if (p === null || (found !== null && p !== found)) return null;
found = p;
}
return found;
}
function checkerPrimitive(type: ts.Type): Primitive | null {
const flags = type.flags;
if (flags & (ts.TypeFlags.String | ts.TypeFlags.StringLiteral)) return "string";
if (flags & (ts.TypeFlags.Number | ts.TypeFlags.NumberLiteral)) return "number";
if (flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral)) return "boolean";
return null;
}
/** The primitive type `e` always evaluates to, or null. The checker answers
* where it knows; where it types `e` as `any`, the value's structure does:
* a read of a decided candidate, arithmetic and comparisons over such
* values, and constants defined from them. An expression nested deeper than
* the lowering's nesting limit answers null. */
function primitiveOf(
lowerer: Lowerer,
bySymbol: Map<ts.Symbol, Candidate>,
e: ts.Expression,
visiting: Set<ts.Node>,
depth = 0,
): Primitive | null {
if (depth > MAX_DEPTH) return null;
const type = lowerer.checker.getTypeAtLocation(e);
if ((type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) === 0) return checkerPrimitive(type);
const of = (x: ts.Expression): Primitive | null =>
primitiveOf(lowerer, bySymbol, x, visiting, depth + 1);
if (ts.isParenthesizedExpression(e)) return of(e.expression);
if (ts.isPrefixUnaryExpression(e)) {
if (e.operator === ts.SyntaxKind.ExclamationToken) return "boolean";
return e.operator !== ts.SyntaxKind.PlusPlusToken &&
e.operator !== ts.SyntaxKind.MinusMinusToken &&
of(e.operand) === "number"
? "number"
: null;
}
if (ts.isConditionalExpression(e)) {
const yes = of(e.whenTrue);
return yes !== null && yes === of(e.whenFalse) ? yes : null;
}
if (ts.isBinaryExpression(e)) {
const op = e.operatorToken.kind;
switch (op) {
case ts.SyntaxKind.LessThanToken:
case ts.SyntaxKind.GreaterThanToken:
case ts.SyntaxKind.LessThanEqualsToken:
case ts.SyntaxKind.GreaterThanEqualsToken:
case ts.SyntaxKind.EqualsEqualsEqualsToken:
case ts.SyntaxKind.ExclamationEqualsEqualsToken:
case ts.SyntaxKind.EqualsEqualsToken:
case ts.SyntaxKind.ExclamationEqualsToken:
return "boolean";
case ts.SyntaxKind.QuestionQuestionToken:
case ts.SyntaxKind.BarBarToken: {
// `m.get(k) ?? d` and `a.pop() || d`: the stored type or a default of it.
const stored = storedRead(lowerer, bySymbol, e.left);
const fallback = of(e.right);
return stored !== null && stored === fallback ? stored : null;
}
case ts.SyntaxKind.PlusToken: {
const left = of(e.left);
const right = of(e.right);
if (left === "number" && right === "number") return "number";
return left !== null && right !== null && (left === "string" || right === "string")
? "string"
: null;
}
case ts.SyntaxKind.MinusToken:
case ts.SyntaxKind.AsteriskToken:
case ts.SyntaxKind.SlashToken:
case ts.SyntaxKind.PercentToken:
case ts.SyntaxKind.AsteriskAsteriskToken:
case ts.SyntaxKind.BarToken:
case ts.SyntaxKind.AmpersandToken:
case ts.SyntaxKind.CaretToken:
case ts.SyntaxKind.LessThanLessThanToken:
case ts.SyntaxKind.GreaterThanGreaterThanToken:
case ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken:
return of(e.left) === "number" && of(e.right) === "number" ? "number" : null;
default:
return null;
}
}
if (ts.isCallExpression(e) || ts.isPropertyAccessExpression(e))
return candidateRead(lowerer, bySymbol, e);
// A constant defined from such an expression.
if (ts.isIdentifier(e) && !visiting.has(e)) {
const symbol = lowerer.checker.getSymbolAtLocation(e);
const declaration = symbol?.valueDeclaration;
if (
declaration !== undefined &&
ts.isVariableDeclaration(declaration) &&
declaration.initializer !== undefined &&
ts.isVariableDeclarationList(declaration.parent) &&
(declaration.parent.flags & ts.NodeFlags.Const) !== 0
) {
visiting.add(e);
const p = primitiveOf(lowerer, bySymbol, declaration.initializer, visiting, depth + 1);
visiting.delete(e);
return p;
}
}
return null;
}
/** The candidate a use reads, when `e` is `c.member` or `c.member(...)` of a
* candidate `c`. */
function candidateOf(
lowerer: Lowerer,
bySymbol: Map<ts.Symbol, Candidate>,
e: ts.Expression,
): { candidate: Candidate; member: string } | null {
const access = ts.isCallExpression(e) ? e.expression : e;
if (!ts.isPropertyAccessExpression(access) || !ts.isIdentifier(access.expression)) return null;
const symbol = lowerer.checker.getSymbolAtLocation(access.expression);
const candidate = symbol !== undefined ? bySymbol.get(symbol) : undefined;
return candidate !== undefined ? { candidate, member: access.name.text } : null;
}
/** The type of a candidate's read that may answer undefined: `m.get(k)`,
* `a.pop()`, `a.shift()`, `a.at(i)`. */
function storedRead(
lowerer: Lowerer,
bySymbol: Map<ts.Symbol, Candidate>,
e: ts.Expression,
): Primitive | null {
while (ts.isParenthesizedExpression(e)) e = e.expression;
if (!ts.isCallExpression(e)) return null;
const read = candidateOf(lowerer, bySymbol, e);
if (read === null) return null;
const { candidate, member } = read;
if (candidate.kind === "map" && member === "get") return candidate.value ?? null;
if (candidate.kind === "array" && (ARRAY_READS.has(member) || member === "at"))
return candidate.value ?? null;
return null;
}
/** The type of a candidate's read that always answers a value: size,
* length, has, includes, indexOf, push. */
function candidateRead(
lowerer: Lowerer,
bySymbol: Map<ts.Symbol, Candidate>,
e: ts.Expression,
): Primitive | null {
const read = candidateOf(lowerer, bySymbol, e);
if (read === null) return null;
const { candidate, member } = read;
if (!ts.isCallExpression(e)) return member === "size" || member === "length" ? "number" : null;
if (member === "has" || member === "includes" || member === "delete") return "boolean";
if (member === "indexOf" || (candidate.kind === "array" && member === "push")) return "number";
if (candidate.kind === "array" && member === "join") return "string";
return null;
}
@@ -116,6 +116,7 @@ import { PoisonError, dynFallbackType, jsFuncNameOf, newFnCtx, own } from "./low
import { isAssertClassNew } from "./assert-instances.js";
import { isCjsRequireGlobal } from "./lower-node-module.js";
import { lowerArrayConstructor } from "./containers/array-construction.js";
import { initializedCollectionType } from "./js-collection-types.js";
import { lowerMapSeedNew, lowerSetSeedNew } from "./containers/collection-methods.js";
import { lowerObjectConstructor } from "./containers/indexed-objects.js";
import { knownBufEncoding, lowerBufferConstruct } from "./containers/bytes.js";
@@ -10699,7 +10700,11 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
// expression). The CONTEXTUAL type carries the annotation — adopt
// it when it is a supported map. TS type arguments keep winning:
// their expression type already maps.
if (mapped?.kind !== "map" || contextualSeed) {
const inferred = initializedCollectionType(lowerer, expr);
if (inferred) {
tsType = inferred;
mapped = lowerer.mapTypeOf(inferred);
} else if (mapped?.kind !== "map" || contextualSeed) {
const ctx = lowerer.checker.getContextualType(expr);
const ctxMapped = ctx ? lowerer.mapTypeOf(ctx) : null;
if (ctx && ctxMapped?.kind === "map") {
@@ -10839,7 +10844,8 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
// `new Set<T>()`: construct + bulk add from supported values,
// including live collection cursors. Duplicates collapse in insertion order.
if (ts.isIdentifier(identifier) && lowerer.isStdlibGlobal(identifier, "Set")) {
const tsType = lowerer.typeOf(expr);
const inferred = initializedCollectionType(lowerer, expr);
const tsType = inferred ?? lowerer.typeOf(expr);
let mapped = lowerer.mapTypeOf(tsType);
const fieldType = assignedThisFieldType(lowerer, expr);
if (mapped?.kind !== "set" && fieldType?.kind === "set") mapped = fieldType;
@@ -156,6 +156,7 @@ import {
trackClassFieldCreation,
} from "./class-dynamic-dispatch.js";
import { guardClassAttributeWrites } from "./class-attributes.js";
import { inferredCollectionType } from "./js-collection-types.js";
import { guardFrozenWrites, markSloppyRecordWrites } from "./frozen-records.js";
import { applyHandleProperties } from "./handle-properties.js";
import { installBufferValue } from "./containers/bytes.js";
@@ -2853,6 +2854,9 @@ export class Lowerer {
/** Parameters read as `any` in the error-path copy of an error-first
* callback. */
readonly anySymbols = new Set<ts.Symbol>();
/** The collection types that uses prove for untyped JavaScript bindings,
* by every identifier naming one (js-collection-types.ts). */
inferredCollectionTypes: Map<ts.Node, ts.Type> | undefined = undefined;
/** overload-choice's answers per call, binding and function. */
readonly dynamicOverloadAnswers = new Map<ts.Node, boolean>();
/** Error parameters of an error-first callback with a known value in
@@ -9075,6 +9079,10 @@ export class Lowerer {
}
typeOf(node: ts.Node): ts.Type {
if (ts.isIdentifier(node)) {
const inferred = inferredCollectionType(this, node);
if (inferred) return inferred;
}
if (this.anySymbols.size > 0 && this.readsAnySymbol(node)) return this.checker.getAnyType();
// A checked-dynamic argument that another overload could take at run
// time leaves the resolved overload's return a guess, and JS bindings
@@ -0,0 +1,64 @@
import { mkdtempSync, readFileSync, rmSync } 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";
// Corpus 7985 exercises every refusal: only the collections whose uses
// prove their types lower as typed collections; the rest stay untyped.
test("untyped JavaScript collections take the types their uses prove, and only those", async () => {
const dir = mkdtempSync(join(tmpdir(), "scriptc-js-collection-types-"));
try {
const entry = join(
import.meta.dirname,
"../../../tests/corpus/7985-untyped-collection-types.mjs",
);
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 types = new Map(module.globals.map((g) => [g.name, JSON.stringify(g.type)]));
const map = (key: string, value: string) =>
JSON.stringify({ kind: "map", key: { kind: key }, value: { kind: value } });
const set = (elem: string) => JSON.stringify({ kind: "set", elem: { kind: elem } });
const array = (elem: string) => JSON.stringify({ kind: "array", elem: { kind: elem } });
// Untyped collections hold checked-dynamic keys, values and elements.
const typed = (t: string) => !t.includes('"dyn"');
expect(Object.fromEntries([...types].filter(([, t]) => typed(t)))).toEqual({
counts: map("string", "f64"),
seen: set("f64"),
stack: array("f64"),
words: array("string"),
byNumber: map("f64", "string"),
nums: array("f64"),
flags: set("bool"),
cache: map("string", "f64"),
"%loaded": '{"kind":"bool"}',
});
for (const untyped of [
"strings",
"numbers",
"mixedKeys",
"mixedValues",
"mixedElements",
"passed",
"stored",
"iterated",
"eachSet",
"shown",
"chained",
"optional",
"indexed",
"truncated",
"spread",
"unread",
"exported",
"listed",
"renamed",
])
expect(typed(types.get(untyped)!), untyped).toBe(false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7985-untyped-collection-types.mjs"
],
"diags": []
}
@@ -0,0 +1,125 @@
// Untyped Maps, Sets and arrays whose uses prove their key, value and
// element types compile as typed collections; any other use keeps them
// untyped. Both must answer as Node does, including SameValueZero keys,
// lookups of another type, and every use that refuses the typing.
// Typed: counters, membership sets and stacks used only through their
// methods.
const counts = new Map();
const seen = new Set();
const stack = [];
const words = [];
for (let i = 0; i < 40; i++) {
const key = "k" + (i % 7);
counts.set(key, (counts.get(key) ?? 0) + 1);
seen.add(i % 5);
stack.push(i * 2);
if (stack.length > 6) stack.shift();
words.push(key);
}
console.log(counts.size, counts.get("k3"), counts.get("missing"), counts.has("k6"), counts.delete("k0"), counts.size);
console.log(seen.size, seen.has(4), seen.has(5), seen.delete(4), seen.has(4));
console.log(stack.length, stack.at(-1), stack.at(0), stack.pop(), stack.includes(70), stack.indexOf(72), words.join("|").length);
// SameValueZero keys and NaN elements on typed collections.
const byNumber = new Map();
byNumber.set(NaN, "nan");
byNumber.set(-0, "zero");
byNumber.set(1.5, "x");
console.log(byNumber.get(NaN), byNumber.get(0), byNumber.get(-0), byNumber.size, byNumber.has(1.5));
const nums = [];
nums.push(NaN);
nums.push(-0);
console.log(nums.includes(NaN), nums.indexOf(NaN), nums.includes(0), nums.indexOf(0));
const flags = new Set();
flags.add(true);
console.log(flags.has(true), flags.has(false), flags.size);
// Captured by a closure that only uses it.
const cache = new Map();
const latest = () => cache.get("c") ?? -1;
console.log(latest());
for (let i = 0; i < 3; i++) cache.set("c", i * 10);
console.log(latest(), cache.size);
// Lookups of another type: the typing refuses, and the answers stay Node's.
const strings = new Map();
strings.set("1", "one");
console.log(strings.get(1), strings.get("1"), strings.has(1));
const numbers = [];
numbers.push(1);
console.log(numbers.includes("1"), numbers.indexOf("1"), numbers.at("0"));
// Mixed key, value and element types.
const mixedKeys = new Map();
mixedKeys.set(1, "number");
mixedKeys.set("1", "string");
console.log(mixedKeys.size, mixedKeys.get(1), mixedKeys.get("1"));
const mixedValues = new Map();
mixedValues.set("a", 1);
mixedValues.set("b", "two");
console.log(mixedValues.get("a"), mixedValues.get("b"));
const mixedElements = [];
mixedElements.push(1);
mixedElements.push("two");
console.log(mixedElements.join(","), mixedElements.length);
// Escapes and other uses that keep the collection untyped.
const passed = new Map();
passed.set("x", 1);
function sizeOf(m) {
return m.size;
}
console.log(sizeOf(passed));
function make() {
const made = new Set();
made.add("y");
return made;
}
console.log(make().has("y"));
const stored = [];
stored.push(3);
const holder = { list: stored };
holder.list.push(4);
console.log(stored.length, holder.list.join("+"));
const iterated = new Map();
iterated.set("a", 1);
iterated.set("b", 2);
for (const [k, v] of iterated) console.log("entry", k, v);
const eachSet = new Set();
eachSet.add(3);
eachSet.forEach((v) => console.log("member", v));
const shown = new Map();
shown.set("s", 1);
console.log(shown);
const chained = new Map();
chained.set("a", 1).set("b", 2);
console.log(chained.size);
const optional = new Map();
optional.set("o", 1);
console.log(optional?.get("o"), optional.get?.("o"));
const indexed = [];
indexed.push(1);
indexed[3] = 2;
console.log(indexed.length, indexed.join(","));
const truncated = [];
truncated.push(1, 2, 3);
truncated.length = 1;
console.log(truncated.length, truncated.at(0));
const spread = [];
spread.push(5);
console.log(Math.max(...spread));
const unread = new Map();
console.log(unread.size, unread.get("none"));
export const exported = new Map();
exported.set("e", 1);
console.log(exported.get("e"));
// Exported through an export list, under its own name or another: an
// importer sees an untyped Map.
const listed = new Map();
listed.set("l", 1);
console.log(listed.get("l"));
const renamed = new Map();
renamed.set("r", 1);
console.log(renamed.get("r"));
export { listed, renamed as alias };
@@ -0,0 +1 @@
{ "plain": 2400, "san": 2600 }