Store fetch Headers, Request, and Response handles in class fields

A class field typed `Headers`, `Request`, or `Response` (or an array or
nullable form of them) maps to checked-dynamic storage, and the class-field
rule refused it (SC1090) although `unknown`, Map values, and closures
already store the same native handles.

Such fields now take the checked-dynamic slot, like the worker handles;
reads keep the handle's identity and the static fetch member lowering.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-18 15:16:17 -07:00
co-authored by Claude Opus 5.5
parent 6c1af69d03
commit 30e23af63f
4 changed files with 76 additions and 3 deletions
@@ -2680,7 +2680,7 @@ export function collectClassShapeInner(
type.kind === "void" ||
(type.kind === "dyn" &&
!isJsSourceFile(member.getSourceFile()) &&
!lowerer.classImplementedProtocol(inferred))
!lowerer.classFieldStoresDyn(inferred))
)
lowerer.badType(member, inferred);
if (isJsSourceFile(member.getSourceFile())) {
@@ -235,6 +235,7 @@ import {
isOsConstantsSymbol,
isUnitOnlyTsType,
isWorkerHandleType,
isFetchHandleType,
jsOpenObjectType,
mapType,
ShapeRegistry,
@@ -3004,12 +3005,14 @@ export class Lowerer {
/** Declared class fields may store a DYN value only when its static type
* is `unknown` or `object` (also with `null` or `undefined`, or an array
* of those), names checked protocol identity, or is an owned runtime
* handle (a Worker or MessagePort); `any` fields stay refused. */
* handle (a Worker or MessagePort, or a fetch Headers/Request/Response);
* `any` fields stay refused. */
classFieldStoresDyn(type: ts.Type): boolean {
return (
this.opaqueValueType(type) ||
this.classImplementedProtocol(type) ||
isWorkerHandleType(type, this.typeCtx)
isWorkerHandleType(type, this.typeCtx) ||
isFetchHandleType(type, this.typeCtx)
);
}
@@ -1313,6 +1313,40 @@ export function isWorkerHandleType(
return handle;
}
/** The native fetch handles (Headers, Request, Response), optionally in
* arrays or with null/undefined: checked-dynamic handles wherever they are
* stored, like the worker handles above. */
export function isFetchHandleType(
type: ts.Type,
ctx: Pick<TypeMapperCtx, "checker" | "isStdlibFile">,
): boolean {
const members = type.isUnionType() ? ts.constituentTypes(type) : [type];
let handle = false;
for (const member of members) {
if (member.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) continue;
if (ctx.checker.isArrayType(member)) {
const element = ctx.checker.getTypeArguments(member as ts.TypeReference)[0];
if (element === undefined || !isFetchHandleType(element, ctx)) return false;
} else {
const symbol = member.getSymbol();
if (
!symbol ||
(symbol.name !== "Headers" && symbol.name !== "Request" && symbol.name !== "Response") ||
!ctx.checker
.declarationsOf(symbol)
.some(
(d) =>
(ts.isInterfaceDeclaration(d) || ts.isClassDeclaration(d)) &&
ctx.isStdlibFile(d.getSourceFile()),
)
)
return false;
}
handle = true;
}
return handle;
}
export function mapType(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
if (mapTypeDepth >= MAP_TYPE_MAX_DEPTH) return null;
// These intrinsic domains cannot depend on generic bindings, declaration
+36
View File
@@ -0,0 +1,36 @@
// Class fields typed as native fetch handles (Headers, Request, Response),
// directly, optional, nullable, in arrays, and inside record fields, hold
// the live handle: mutation through one reference is seen through another.
class Holder {
headers: Headers;
list: Headers[] = [];
maybe?: Response;
req: Request | null = null;
bag: { headers: Headers; status: number };
constructor(headers: Headers) {
this.headers = headers;
this.bag = { headers, status: 1 };
}
}
const holder = new Holder(new Headers({ a: "1" }));
console.log(holder.headers.get("a"), holder.list.length, holder.maybe === undefined, holder.req);
holder.list.push(new Headers({ b: "2" }));
holder.maybe = new Response("x", { status: 201 });
holder.req = new Request("http://localhost/p", { method: "POST" });
console.log(holder.list[0]!.get("b"), holder.maybe.status, holder.req.method, holder.req.url);
holder.headers.set("c", "3");
console.log(holder.bag.headers.get("c"), holder.bag.headers === holder.headers);
// Symbol-keyed fields hold the handles the same way.
const NATIVE = Symbol("native");
class Wrapper {
[NATIVE]: Response;
constructor(body: string) {
this[NATIVE] = new Response(body, { status: 202 });
}
get status(): number {
return this[NATIVE].status;
}
}
const wrapped = new Wrapper("ok");
console.log(wrapped.status, wrapped[NATIVE].headers.get("content-type"));