diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index c7e8155ed..2efe14160 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2112,6 +2112,51 @@ export class Lowerer { return fallback ?? null; } + /** A TypeScript object literal handed to a JavaScript checked-value + * parameter whose inferred type is open (`constructor({ factory, ...options + * } = {})` — the rest element admits any property): the literal names a + * property the contextual JS type does not declare, so it has no record + * layout there and is built as the plain object the JS side receives. */ + private excessJsContextualProperty( + literal: ts.ObjectLiteralExpression, + contextual: readonly ts.Type[], + ): boolean { + const objects = contextual.filter( + (part) => !(part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null)), + ); + if (objects.length === 0) return false; + // Only a destructuring pattern with a rest element is open; other + // inferred JS types keep their record lowering. + const fromJs = objects.every((part) => { + const sym = part.getSymbol(); + return ( + sym !== undefined && + this.checker.declarationsOf(sym).some((d) => { + if (!isJsSourceFile(d.getSourceFile())) return false; + // The pattern itself, or the `= {}` default of a pattern parameter. + const pattern = + ts.isObjectLiteralExpression(d) && + d.parent !== undefined && + ts.isParameter(d.parent) && + d.parent.initializer === d + ? d.parent.name + : d; + return ( + ts.isObjectBindingPattern(pattern) && + pattern.elements.nodes.some((element) => element.dotDotDotToken !== undefined) + ); + }) + ); + }); + if (!fromJs) return false; + return literal.properties.nodes.some((prop) => { + if (!prop.name || !(ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name))) + return false; + const name = prop.name.text; + return objects.every((part) => !this.checker.getPropertyOfType(part, name)); + }); + } + /** The index value of a keyed read of a record's index signature that * can answer undefined for a missing key while its checker type does not * admit undefined, or null. A literal key naming a declared field is a @@ -9615,7 +9660,8 @@ export class Lowerer { builtinOption("SpawnOptions") || builtinOption("UrlObject") || builtinOption("URLFormatOptions") || - !this.mapTypeOf(this.typeOf(x)) + !this.mapTypeOf(this.typeOf(x)) || + this.excessJsContextualProperty(x, parts) ) { return lowerDynObjectLiteral(this, x); } diff --git a/tests/corpus/cjs-open-options-npm/main.ts b/tests/corpus/cjs-open-options-npm/main.ts new file mode 100644 index 000000000..9fd133155 --- /dev/null +++ b/tests/corpus/cjs-open-options-npm/main.ts @@ -0,0 +1,12 @@ +// TypeScript object literals passed to a JavaScript constructor whose +// options parameter is a destructuring pattern with a rest element: the +// literal's extra properties reach the rest object. +import { Agent } from "optkit"; + +console.log(new Agent().describe("a")); +console.log(new Agent({ keepAliveTimeout: 10 }).describe("b")); +console.log(new Agent({ maxOrigins: 2, keepAliveTimeout: 5, pipelining: 1 }).describe("c")); +console.log(new Agent({ factory: (origin) => "client(" + origin + ")" }).describe("d")); + +class TracingAgent extends Agent {} +console.log(new TracingAgent({ pipelining: 3 }).describe("e")); diff --git a/tests/corpus/cjs-open-options-npm/node_modules/optkit/index.d.ts b/tests/corpus/cjs-open-options-npm/node_modules/optkit/index.d.ts new file mode 100644 index 000000000..643b92d84 --- /dev/null +++ b/tests/corpus/cjs-open-options-npm/node_modules/optkit/index.d.ts @@ -0,0 +1,11 @@ +export interface Options { + factory?: (origin: string) => string; + maxOrigins?: number; + keepAliveTimeout?: number; + pipelining?: number; +} + +export declare class Agent { + constructor(opts?: Options); + describe(origin: string): string; +} diff --git a/tests/corpus/cjs-open-options-npm/node_modules/optkit/index.js b/tests/corpus/cjs-open-options-npm/node_modules/optkit/index.js new file mode 100644 index 000000000..0034fc442 --- /dev/null +++ b/tests/corpus/cjs-open-options-npm/node_modules/optkit/index.js @@ -0,0 +1,21 @@ +'use strict'; + +function defaultFactory(origin) { + return 'pool(' + origin + ')'; +} + +// undici's Agent constructor shape: named options with defaults, and a rest +// element collecting every other option for the per-origin dispatchers. +class Agent { + constructor({ factory = defaultFactory, maxOrigins = Infinity, ...options } = {}) { + this.factory = factory; + this.maxOrigins = maxOrigins; + this.options = options; + } + + describe(origin) { + return this.factory(origin) + ' max=' + this.maxOrigins + ' ' + JSON.stringify(this.options); + } +} + +module.exports = { Agent }; diff --git a/tests/corpus/cjs-open-options-npm/node_modules/optkit/package.json b/tests/corpus/cjs-open-options-npm/node_modules/optkit/package.json new file mode 100644 index 000000000..9552d73ee --- /dev/null +++ b/tests/corpus/cjs-open-options-npm/node_modules/optkit/package.json @@ -0,0 +1,6 @@ +{ + "name": "optkit", + "version": "1.0.0", + "main": "index.js", + "types": "index.d.ts" +}