Pass TypeScript option literals to open JavaScript option parameters

`new Agent({ keepAliveTimeout: 10 })` against undici's
`constructor({ factory = defaultFactory, maxOrigins = Infinity, ...options }
= {})` failed with SC2002: the literal was laid out against the record the
checker infers for the pattern, which lacks the properties the rest
element collects.

When a TypeScript object literal flows into a checked-value slot whose
contextual type is a JavaScript destructuring pattern with a rest element
(or that pattern's `= {}` default), and the literal names a property the
type does not declare, the literal is built as the plain object the
JavaScript side receives. Every other literal keeps its previous lowering.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-19 14:09:31 -07:00
co-authored by Claude Opus 5.5
parent 7b1556e0f6
commit 3c76868fca
5 changed files with 97 additions and 1 deletions
@@ -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);
}
+12
View File
@@ -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"));
+11
View File
@@ -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;
}
+21
View File
@@ -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 };
+6
View File
@@ -0,0 +1,6 @@
{
"name": "optkit",
"version": "1.0.0",
"main": "index.js",
"types": "index.d.ts"
}