Keep island arrays of intersection types as handles

`Choice[] & GenChoice[]`, an island array extended by the caller's own choice
type (pi's openrouter images: `imageResponse.choices`), was refused with
SC8010. The intersection has no native IR type of its own — mapTypeOf
returns null, or its arms map to different array types — so the crossing
failed before the container rules that keep an array the island does not
declare a handle.

An intersection of containers is a container: it is a value of each arm, and
where the program only uses it through island operations (member reads, an
island call, an island identity test, a handle binding) it now stays a
handle, exactly as an undeclared array does. Elements and fields the
declaration does name cross back out on their own terms, so an element's
`images` array copies, and any other use still refuses rather than inventing
a native layout.

Test: tests/corpus/6901-node-island-array-intersection (both shapes, the
island-declared arm and the caller's own arm, 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 6ba4e6e52a
commit d2e3690bc3
8 changed files with 79 additions and 2 deletions
@@ -681,12 +681,19 @@ function fromIsland(lowerer: Lowerer, raw: IrExpr, node: ts.Expression): IrExpr
if ((tsType.flags & (ts.TypeFlags.Void | ts.TypeFlags.Undefined)) !== 0 && isDiscarded(node))
return raw;
const mapped = lowerer.mapTypeOf(tsType);
if (mapped === null)
if (mapped === null) {
// An intersection of containers (`Choice[] & GenChoice[]`: the
// island's array extended by the caller's own type) has no native
// representation of its own. Where the program only uses it through
// island operations it stays a handle, like an array the island does
// not declare.
if (containerType(lowerer, tsType) && handleUseOk(lowerer, node)) return raw;
boundaryFail(
lowerer,
`an island value of type '${lowerer.checker.typeToString(tsType)}'`,
node,
);
}
if (mapped.kind === "dyn") return raw;
if (mapped.kind === "void") return raw;
// An island promise is the same promise on both sides (the runtime keeps
@@ -887,7 +894,8 @@ function builtinHandleClass(type: ts.Type): boolean {
/** Arrays, tuples, Maps and Sets (every arm of a union, nullish arms
* aside), and the builtin classes of builtinHandleClass: the object types
* an island hands back without declaring them. */
* an island hands back without declaring them. An intersection of those
* (`Choice[] & GenChoice[]`) is a container too: it is a value of each. */
function containerType(lowerer: Lowerer, type: ts.Type): boolean {
if (type.isUnionType())
return ts
@@ -897,6 +905,14 @@ function containerType(lowerer: Lowerer, type: ts.Type): boolean {
(t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0 ||
containerType(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 ||
containerType(lowerer, t),
);
if ((type.flags & ts.TypeFlags.Object) === 0) return false;
if (lowerer.checker.isArrayType(type) || lowerer.checker.isTupleType(type)) return true;
const name = type.getSymbol()?.escapedName;
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6901-node-island-array-intersection/main.ts"
],
"diags": []
}
@@ -0,0 +1,24 @@
// @requires-libnode
// Reading an element of an island array whose declared type is an
// INTERSECTION of two array types — `Choice[] & GenChoice[]`, the island's
// choices extended by the caller's own choice type — has no native
// representation of its own, so the array stays a handle and the read runs
// in the island; the fields the declaration names cross back out.
import { extendedImageResponse, imageResponse, unsafe } from "choicekit";
import type { Choice } from "choicekit";
const extended = extendedImageResponse();
const first = extended.choices[0];
console.log(first.index, first.text);
console.log(first.images === undefined ? "no-images" : first.images.join("+"));
const second = extended.choices[1];
console.log(second.index, second.text, second.images === undefined ? "no-images" : second.images.join("+"));
/** The caller's own choice type over the island's array. */
interface NativeChoice extends Choice {
readonly images?: string[];
}
const third = unsafe<Choice[] & NativeChoice[]>(imageResponse().choices)[1];
console.log(third.text, third.images === undefined ? "no-images" : third.images.join("+"));
@@ -0,0 +1,18 @@
export interface Choice {
readonly index: number;
readonly text: string;
}
export interface GenChoice extends Choice {
readonly images?: string[];
}
export interface ImageResponse {
readonly choices: Choice[];
}
export interface ExtendedImageResponse {
/** The island's choices, extended by the island's own choice type. */
readonly choices: Choice[] & GenChoice[];
}
export declare function imageResponse(): ImageResponse;
export declare function extendedImageResponse(): ExtendedImageResponse;
/** Declares the type of the value it is given, like `Type.Unsafe<T>`. */
export declare function unsafe<T>(value: T): T;
@@ -0,0 +1,10 @@
"use strict";
const choices = () => [
{ index: 0, text: "first", images: ["a.png", "b.png"] },
{ index: 1, text: "second" },
];
exports.imageResponse = () => ({ choices: choices() });
exports.extendedImageResponse = () => ({ choices: choices() });
exports.unsafe = (value) => value;
@@ -0,0 +1 @@
{ "name": "choicekit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
@@ -0,0 +1 @@
{ "name": "island-array-intersection", "private": true, "type": "module", "scriptc": { "packages": { "choicekit": "node" } } }
@@ -0,0 +1 @@
{ "plain": 3000, "san": 42000 }