Export merged record types into islands by reference

A merged generic record crossing INTO an island — typebox's
`Type.Object(props)` with pi's `Omit<ProviderBase, "retries"> & 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 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-29 19:14:37 -07:00
co-authored by Claude Opus 5.5
parent ea9b70d1ad
commit 6ba4e6e52a
8 changed files with 74 additions and 1 deletions
@@ -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<Base, "k"> & 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;
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6900-node-island-merged-record-in/main.ts"
],
"diags": []
}
@@ -0,0 +1,31 @@
// @requires-libnode
// A merged generic record crossing INTO the island (`object<T>(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<ProviderBase, "retries"> & 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);
@@ -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<T>`: 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<T>(value: T): T;
/** A schema over the properties record it is given, like `Type.Object`. */
export declare function object<T>(properties: T): Schema & { readonly properties: T };
/** The sorted own keys of a value, joined. */
export declare function keys(value: unknown): string;
@@ -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(",");
@@ -0,0 +1 @@
{ "name": "recordkit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
@@ -0,0 +1 @@
{ "name": "node-island-merged-record-in", "private": true, "type": "module", "scriptc": { "packages": { "recordkit": "node" } } }
@@ -0,0 +1 @@
{ "plain": 3000, "san": 34000 }