diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index 053e5039f..76456833e 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -1226,7 +1226,11 @@ export function classConstructHelper( /** Native values NI-5 exports into the island by reference: functions, * promises, and object types other than arrays, tuples, Maps and Sets * (records, interfaces, class instances). A union qualifies when every - * arm does or is copied. */ + * arm does or is copied, and an intersection of them qualifies: a merged + * generic record (`Omit & Extras`, the `Type.Object(props)` + * shape) is one native object, and the island reads the program's own + * object, not a copy of it. An intersection with a primitive arm + * (`string & {brand}`) is a primitive value, which the runtime copies. */ function exportedByReference(lowerer: Lowerer, type: ts.Type): boolean { const checker = lowerer.checker; if (type.isUnionType()) @@ -1237,6 +1241,14 @@ function exportedByReference(lowerer: Lowerer, type: ts.Type): boolean { (t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0 || exportedByReference(lowerer, t), ); + if ((type.flags & ts.TypeFlags.Intersection) !== 0) + return ts + .constituentTypes(type) + .every( + (t) => + (t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0 || + exportedByReference(lowerer, t), + ); if ((type.flags & ts.TypeFlags.Object) === 0) return false; // Arrays and tuples cross as Proxies over the native array (NI-5b). void checker; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6900-node-island-merged-record-in/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6900-node-island-merged-record-in/main.ts.json new file mode 100644 index 000000000..4818b61b1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6900-node-island-merged-record-in/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6900-node-island-merged-record-in/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6900-node-island-merged-record-in/main.ts b/tests/corpus/6900-node-island-merged-record-in/main.ts new file mode 100644 index 000000000..51e286916 --- /dev/null +++ b/tests/corpus/6900-node-island-merged-record-in/main.ts @@ -0,0 +1,31 @@ +// @requires-libnode +// A merged generic record crossing INTO the island (`object(properties)` +// is `Type.Object(props)`): the merged type is an intersection — the base's +// fields without one, plus the overrides — and the island reads the +// program's own object, not a copy of it. +import { keys, object } from "recordkit"; + +interface ProviderBase { + name: string; + retries: number; +} + +interface ProviderOverrides { + stream: boolean; +} + +/** A merged record spelled as an intersection of a mapped type and an interface. */ +type MergedProvider = Omit & ProviderOverrides; + +function merged(name: string, stream: boolean): MergedProvider { + return { name, stream }; +} + +const props: MergedProvider = merged("openai", true); +const schema = object(props); +console.log(schema.kind, schema.describe()); +console.log(keys(props), keys(schema.properties)); +console.log(schema.properties.name, schema.properties.stream); + +const inline = object({ name: "anthropic", stream: false }); +console.log(inline.kind, inline.describe(), inline.properties.name); diff --git a/tests/corpus/6900-node-island-merged-record-in/node_modules/recordkit/index.d.ts b/tests/corpus/6900-node-island-merged-record-in/node_modules/recordkit/index.d.ts new file mode 100644 index 000000000..ed760c9d1 --- /dev/null +++ b/tests/corpus/6900-node-island-merged-record-in/node_modules/recordkit/index.d.ts @@ -0,0 +1,12 @@ +export interface Schema { + readonly kind: string; + describe(): string; +} +/** Declares the type of the value it is given, like typebox's + * `Type.Unsafe`: the island hands back the caller's own type, so the + * caller's declaration must describe what the island really returns. */ +export declare function unsafe(value: T): T; +/** A schema over the properties record it is given, like `Type.Object`. */ +export declare function object(properties: T): Schema & { readonly properties: T }; +/** The sorted own keys of a value, joined. */ +export declare function keys(value: unknown): string; diff --git a/tests/corpus/6900-node-island-merged-record-in/node_modules/recordkit/index.js b/tests/corpus/6900-node-island-merged-record-in/node_modules/recordkit/index.js new file mode 100644 index 000000000..4dec16d1f --- /dev/null +++ b/tests/corpus/6900-node-island-merged-record-in/node_modules/recordkit/index.js @@ -0,0 +1,9 @@ +"use strict"; + +exports.unsafe = (value) => value; +exports.object = (properties) => ({ + kind: "object", + describe: () => "object " + Object.keys(properties).sort().join(","), + properties, +}); +exports.keys = (value) => Object.keys(value).sort().join(","); diff --git a/tests/corpus/6900-node-island-merged-record-in/node_modules/recordkit/package.json b/tests/corpus/6900-node-island-merged-record-in/node_modules/recordkit/package.json new file mode 100644 index 000000000..ef697c47c --- /dev/null +++ b/tests/corpus/6900-node-island-merged-record-in/node_modules/recordkit/package.json @@ -0,0 +1 @@ +{ "name": "recordkit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6900-node-island-merged-record-in/package.json b/tests/corpus/6900-node-island-merged-record-in/package.json new file mode 100644 index 000000000..1f5c5ec87 --- /dev/null +++ b/tests/corpus/6900-node-island-merged-record-in/package.json @@ -0,0 +1 @@ +{ "name": "node-island-merged-record-in", "private": true, "type": "module", "scriptc": { "packages": { "recordkit": "node" } } } diff --git a/tests/harness/corpus-costs/6900-node-island-merged-record-in/main.ts.json b/tests/harness/corpus-costs/6900-node-island-merged-record-in/main.ts.json new file mode 100644 index 000000000..b7c2b4e52 --- /dev/null +++ b/tests/harness/corpus-costs/6900-node-island-merged-record-in/main.ts.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 34000 }