diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts
index a54681b73..053e5039f 100644
--- a/packages/compiler/src/frontend/lowering/lower-node-island.ts
+++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts
@@ -739,7 +739,7 @@ function fromIsland(lowerer: Lowerer, raw: IrExpr, node: ts.Expression): IrExpr
};
return lowerer.coerceToExpected(copy, mapped);
}
- if (!copiedType(lowerer, mapped) && exportedByReference(lowerer, tsType))
+ if (!copiedType(lowerer, mapped) && copiedRecordType(lowerer, tsType))
return lowerer.coerceToExpected(raw, mapped);
if (!copiedType(lowerer, mapped))
boundaryFail(
@@ -1245,6 +1245,25 @@ function exportedByReference(lowerer: Lowerer, type: ts.Type): boolean {
return true;
}
+/** A record type an island object crosses OUT into as a deep checked
+ * copy: the object types exportedByReference names, and intersections of
+ * them — a mapped pair (`{[K in Fg]: V} & {[K in Bg]?: V}`) or a merged
+ * generic record (`Omit & Extras`) is one record, so the
+ * island's object converts into the native one the same way a plain
+ * record's does. An intersection with a primitive arm (`string & {brand}`)
+ * is a primitive value, which copies as its arm. */
+function copiedRecordType(lowerer: Lowerer, type: ts.Type): boolean {
+ 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 ||
+ copiedRecordType(lowerer, t),
+ );
+ return exportedByReference(lowerer, type);
+}
+
/** A fresh object or array literal crosses as a copy (a plain dyn object
* or array the runtime copies into V8): nothing native can alias it. Its
* members cross by the same rules (toIsland). */
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6899-node-island-unsafe-record/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6899-node-island-unsafe-record/main.ts.json
new file mode 100644
index 000000000..f1fc0a68d
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6899-node-island-unsafe-record/main.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/6899-node-island-unsafe-record/main.ts"
+ ],
+ "diags": []
+}
diff --git a/tests/corpus/6899-node-island-unsafe-record/main.ts b/tests/corpus/6899-node-island-unsafe-record/main.ts
new file mode 100644
index 000000000..8eb8d894e
--- /dev/null
+++ b/tests/corpus/6899-node-island-unsafe-record/main.ts
@@ -0,0 +1,21 @@
+// @requires-libnode
+// A record the island hands back (`unsafe` declares the caller's own
+// type, like typebox's `Type.Unsafe`) crosses out as a deep checked
+// copy: the merged type is a required half and an optional half, and the
+// native value carries every field the declaration promises.
+import { unsafe } from "recordkit";
+
+/** A mapped pair of halves — one record, spelled as an intersection. */
+type ColorValues = { [K in "fg" | "bg"]: Value } & { [K in "accent"]?: Value };
+
+const colors = unsafe>({ fg: "red", bg: "blue" });
+console.log(colors.fg, colors.bg, colors.accent === undefined, Object.keys(colors).join(","));
+
+const levels = unsafe>({ fg: 1, bg: 2, accent: 3 });
+console.log(levels.fg + levels.bg, levels.accent, Object.keys(levels).join(","));
+
+/** Native code reads the copy's fields by their declared types. */
+function describe(values: ColorValues): string {
+ return `${values.fg}/${values.bg}/${values.accent ?? "-"}`;
+}
+console.log(describe(colors), describe({ fg: "green", bg: "black" }));
diff --git a/tests/corpus/6899-node-island-unsafe-record/node_modules/recordkit/index.d.ts b/tests/corpus/6899-node-island-unsafe-record/node_modules/recordkit/index.d.ts
new file mode 100644
index 000000000..ed760c9d1
--- /dev/null
+++ b/tests/corpus/6899-node-island-unsafe-record/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/6899-node-island-unsafe-record/node_modules/recordkit/index.js b/tests/corpus/6899-node-island-unsafe-record/node_modules/recordkit/index.js
new file mode 100644
index 000000000..4dec16d1f
--- /dev/null
+++ b/tests/corpus/6899-node-island-unsafe-record/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/6899-node-island-unsafe-record/node_modules/recordkit/package.json b/tests/corpus/6899-node-island-unsafe-record/node_modules/recordkit/package.json
new file mode 100644
index 000000000..ef697c47c
--- /dev/null
+++ b/tests/corpus/6899-node-island-unsafe-record/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/6899-node-island-unsafe-record/package.json b/tests/corpus/6899-node-island-unsafe-record/package.json
new file mode 100644
index 000000000..0b88adb53
--- /dev/null
+++ b/tests/corpus/6899-node-island-unsafe-record/package.json
@@ -0,0 +1 @@
+{ "name": "node-island-unsafe-record", "private": true, "type": "module", "scriptc": { "packages": { "recordkit": "node" } } }
diff --git a/tests/fixtures/node-island/unsafe-record-lie.ts b/tests/fixtures/node-island/unsafe-record-lie.ts
new file mode 100644
index 000000000..057724d71
--- /dev/null
+++ b/tests/fixtures/node-island/unsafe-record-lie.ts
@@ -0,0 +1,12 @@
+// `unsafe` declares the caller's own type and hands back the value it
+// was given: the record crossing out is a deep CHECKED copy, so a field
+// that contradicts the declaration throws a TypeError instead of entering a
+// native record (a corpus program cannot pin this: Node prints the field).
+import { unsafe } from "recordkit";
+
+type ColorValues = { [K in "fg" | "bg"]: string } & { [K in "accent"]?: string };
+
+const raw: unknown = JSON.parse('{"fg":1,"bg":"blue"}');
+console.log("before");
+const colors = unsafe(raw as ColorValues);
+console.log("after", colors.fg);
diff --git a/tests/harness/corpus-costs/6899-node-island-unsafe-record/main.ts.json b/tests/harness/corpus-costs/6899-node-island-unsafe-record/main.ts.json
new file mode 100644
index 000000000..f350af81d
--- /dev/null
+++ b/tests/harness/corpus-costs/6899-node-island-unsafe-record/main.ts.json
@@ -0,0 +1 @@
+{ "plain": 2900, "san": 33000 }
diff --git a/tests/harness/node-island-handles.test.ts b/tests/harness/node-island-handles.test.ts
index 650036bf4..5632a18ef 100644
--- a/tests/harness/node-island-handles.test.ts
+++ b/tests/harness/node-island-handles.test.ts
@@ -210,6 +210,32 @@ describe.skipIf(skip)("Node island handles", () => {
);
}, 300_000);
+ test("an island record that contradicts its declaration throws at the crossing", async () => {
+ // A record the island hands back crosses as a deep checked copy:
+ // `unsafe` declares the caller's own record type and hands back a
+ // JSON object whose `fg` is a number, so the copy's conversion throws
+ // before a number reaches a native string field.
+ const root = join(outDir, "unsafe-record-lie");
+ rmSync(root, { recursive: true, force: true });
+ cpSync(join(repoRoot, "tests/corpus/6899-node-island-unsafe-record"), root, {
+ recursive: true,
+ });
+ copyFileSync(
+ join(repoRoot, "tests/fixtures/node-island/unsafe-record-lie.ts"),
+ join(root, "main.ts"),
+ );
+ const result = await compile(join(root, "main.ts"), {
+ outPath: join(root, "main"),
+ outDir: root,
+ sanitize,
+ });
+ if (!result.ok)
+ throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
+ expect(await refusal(result.binaryPath)).toContain(
+ "TypeError: expected string at $.fg, got number",
+ );
+ }, 300_000);
+
test("an island package ships with the program", async () => {
const root = join(outDir, "ships");
const src = join(root, "src");