From 6ba4e6e52adeac0f9f1f72a36352ccbf80dd50dd Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:10:18 -0700 Subject: [PATCH] Export merged record types into islands by reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A merged generic record crossing INTO an island — typebox's `Type.Object(props)` with pi's `Omit & Extras` (compat-schema.ts) — was refused with SC8010 ("a native object would cross as a copy"). exportedByReference, which decides that a native record crosses as itself, requires the object flag, so an intersection of records fell through its union test and was refused. An intersection of the types exportedByReference names is now one of them: a merged generic record is one native object, and the island reads the program's own object rather than a copy of it, so an island call over `props` and over `schema.properties` sees the same object Node does. An intersection with a primitive arm (`string & {brand}`) stays a primitive and is copied. Test: tests/corpus/6900-node-island-merged-record-in (differential to Node, plain and sanitized). Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/lower-node-island.ts | 14 ++++++++- .../main.ts.json | 6 ++++ .../6900-node-island-merged-record-in/main.ts | 31 +++++++++++++++++++ .../node_modules/recordkit/index.d.ts | 12 +++++++ .../node_modules/recordkit/index.js | 9 ++++++ .../node_modules/recordkit/package.json | 1 + .../package.json | 1 + .../main.ts.json | 1 + 8 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6900-node-island-merged-record-in/main.ts.json create mode 100644 tests/corpus/6900-node-island-merged-record-in/main.ts create mode 100644 tests/corpus/6900-node-island-merged-record-in/node_modules/recordkit/index.d.ts create mode 100644 tests/corpus/6900-node-island-merged-record-in/node_modules/recordkit/index.js create mode 100644 tests/corpus/6900-node-island-merged-record-in/node_modules/recordkit/package.json create mode 100644 tests/corpus/6900-node-island-merged-record-in/package.json create mode 100644 tests/harness/corpus-costs/6900-node-island-merged-record-in/main.ts.json 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 }