Cross island records of intersection types out as copies

An island export whose declared type is an INTERSECTION of record types —
typebox's `Type.Unsafe<{[K in "fg" | "bg"]: V} & {[K in "accent"]?: V}>`,
the mapped halves of pi's ThemeColorValues read off the island's theme
schema — was refused with SC8010 ("an island value of type … only primitives
and strings are copied"). The crossing classified values with
exportedByReference, which requires the object flag, so an intersection of
records looked like a value with no native representation even though
mapTypeOf maps it to the record its parts make.

The copy decision for a value crossing OUT is now copiedRecordType: the
object types exportedByReference names, and intersections of them (nullish
arms aside). Such a value crosses as the deep checked copy the record path
already uses, so a field that contradicts the declaration throws
`TypeError: expected string at $.fg, got number` instead of entering
natively typed memory (types don't lie). Copy, not view, for the reason
9a8e964 chose for arrays: nothing native can alias the island's object.

Test: tests/corpus/6899-node-island-unsafe-record (differential to Node,
plain and sanitized) and the lying-record case in
tests/harness/node-island-handles.test.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-29 19:14:36 -07:00
co-authored by Claude Opus 5.5
parent 52ef883a48
commit ea9b70d1ad
10 changed files with 109 additions and 1 deletions
@@ -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<Base, "k"> & 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). */
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6899-node-island-unsafe-record/main.ts"
],
"diags": []
}
@@ -0,0 +1,21 @@
// @requires-libnode
// A record the island hands back (`unsafe<T>` declares the caller's own
// type, like typebox's `Type.Unsafe<T>`) 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<Value> = { [K in "fg" | "bg"]: Value } & { [K in "accent"]?: Value };
const colors = unsafe<ColorValues<string>>({ fg: "red", bg: "blue" });
console.log(colors.fg, colors.bg, colors.accent === undefined, Object.keys(colors).join(","));
const levels = unsafe<ColorValues<number>>({ 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>): string {
return `${values.fg}/${values.bg}/${values.accent ?? "-"}`;
}
console.log(describe(colors), describe({ fg: "green", bg: "black" }));
@@ -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-unsafe-record", "private": true, "type": "module", "scriptc": { "packages": { "recordkit": "node" } } }
+12
View File
@@ -0,0 +1,12 @@
// `unsafe<T>` 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<ColorValues>(raw as ColorValues);
console.log("after", colors.fg);
@@ -0,0 +1 @@
{ "plain": 2900, "san": 33000 }
+26
View File
@@ -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<T>` 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");