Skip to content

Generated TypeScript declarations omit receiver requirements enforced by JSG/V8 #6904

Description

@teamleaderleo

Summary

JSG registers ordinary resource methods with an owning V8 Signature, so current workerd rejects calls made with an unrelated JavaScript receiver. The generated TypeScript declarations omit that receiver requirement and present those methods as freely rebindable functions.

For Worker global operations such as fetch, TypeScript therefore accepts code that current workerd rejects before the native callback executes:

class Client {
  fetchImpl = fetch;

  run() {
    return this.fetchImpl("data:text/plain,ok");
  }
}

this.fetchImpl(...) supplies the Client instance as this, producing TypeError: Illegal invocation. This proposal requests no runtime behavior change. It asks whether generated Worker declarations should expose receiver requirements using explicit TypeScript this parameters.

User impact

This gap is difficult to catch locally:

  • generated Worker declarations accept the code;
  • Bun and Node tests can also pass because their global fetch implementations tolerate unrelated receivers;
  • Chromium and workerd reject the same call;
  • the exception occurs before outbound I/O and can be mistaken for a network failure;
  • an arrow wrapper or explicit binding repairs the application boundary, but the generated declarations could provide an earlier diagnostic.

A production OAuth adapter encountered this path after storing ambient Worker fetch on a client instance. Detailed downstream investigation: teamleaderleo/stensibly#474

Runtime reproduction

Tested with:

  • workerd package 1.20260728.1 / workerd 2026-07-28
  • Chromium 144.0.7559.96
  • Bun 1.3.14
  • Node 26.5.0

Harness target: data:text/plain,receiver-ok.

const url = "data:text/plain,receiver-ok";
const detached = globalThis.fetch;
const holder = { fetch: detached };

await fetch(url);
await globalThis.fetch(url);
await self.fetch(url);
await detached(url);
await detached.call(undefined, url);
await detached.call(globalThis, url);
await detached.call({}, url);
await holder.fetch(url);
Call form workerd Chromium Bun Node
fetch(url) response response response response
globalThis.fetch(url) response response response response
self.fetch(url) response response response unavailable
detached(url) response response response response
detached.call(undefined, url) response response response response
detached.call(globalThis, url) response response response response
detached.call({}, url) illegal invocation illegal invocation response response
holder.fetch(url) illegal invocation illegal invocation response response

Exact workerd error:

TypeError: Illegal invocation: function called with incorrect `this` reference. See https://developers.cloudflare.com/workers/observability/errors/#illegal-invocation-errors for details.

Commands and raw output: teamleaderleo/stensibly#474 (comment)

Runtime enforcement trace

ServiceWorkerGlobalScope declares fetch as a C++ member and registers it with JSG_METHOD(fetch):

  • jsg::Optional<Request::Initializer> requestInitr);
    jsg::Ref<ServiceWorkerGlobalScope> getSelf() {
    return JSG_THIS;
    }
    // Implemented in global-scope.c++ to avoid including crypto.h
  • JSG_NESTED_TYPE(WorkerGlobalScope);
    if (flags.getSpecCompliantPropertyAttributes()) {
    // EventTarget is also declared on WorkerGlobalScope, but V8's
    // FunctionTemplate::Inherit() does not propagate instance-template
    // properties. Redeclare here so it becomes an own property of globalThis.
    JSG_NESTED_TYPE(EventTarget);
    }
    JSG_METHOD(btoa);
    JSG_METHOD(atob);
    JSG_METHOD(setTimeout);
    JSG_METHOD(clearTimeout);
    JSG_METHOD(setInterval);

JSG creates a v8::Signature specifically to protect methods from the wrong this, and registerMethod() attaches it to the method template:

  • auto prototype = constructor->PrototypeTemplate();
    // Signatures protect our methods from being invoked with the wrong `this`.
    auto signature = v8::Signature::New(isolate, constructor);
    auto instance = constructor->InstanceTemplate();
    instance->SetInternalFieldCount(Wrappable::INTERNAL_FIELD_COUNT);
  • auto functionTemplate = v8::FunctionTemplate::NewWithCFunctionOverloads(isolate,
    &MethodCallback<TypeWrapper, name, isContext, Self, decltype(method), method,
    ArgumentIndexes<decltype(method)>>::callback,
    v8::Local<v8::Value>(), signature, length, v8::ConstructorBehavior::kThrow,
    v8::SideEffectType::kHasSideEffect, {&cFunction, 1});
    prototype->Set(isolate, name, functionTemplate);
    return;
    }
    }
    prototype->Set(isolate, name,
    v8::FunctionTemplate::New(isolate,
    &MethodCallback<TypeWrapper, name, isContext, Self, decltype(method), method,
    ArgumentIndexes<decltype(method)>>::callback,
    v8::Local<v8::Value>(), signature, length, v8::ConstructorBehavior::kThrow));
    }
    template <const char* name, typename Method, Method method>
    inline void registerStaticMethod() {
    // Per Web IDL, function .length = number of required arguments.
    constexpr int specLength = requiredArgumentCount<TypeWrapper, Method>;
    const int length = getSpecCompliantPropertyAttributes(isolate) ? specLength : 0;
    if constexpr (isFastApiCompatible<Method>) {
    if (typeWrapper.isFastApiEnabled()) {
    // Must outlive the FunctionTemplate; see registerMethod for details.
    static const auto cFunction = v8::CFunction::Make(StaticMethodCallback<TypeWrapper, name,
    Self, Method, method, ArgumentIndexes<Method>>::template fastCallback<>);
    // Create a function template with both slow and fast paths

V8 converts undefined/null to the global proxy, then validates the resulting receiver against the signature. Unrelated objects remain unrelated and produce Illegal invocation:

This matches Chromium and Web IDL operation binding behavior.

Where generated declarations lose the receiver

Current declarations are receiver-free:

interface ServiceWorkerGlobalScope extends WorkerGlobalScope {
  fetch(
    input: RequestInfo | URL,
    init?: RequestInit<RequestInitCfProperties>,
  ): Promise<Response>;
}

declare function fetch(
  input: RequestInfo | URL,
  init?: RequestInit<RequestInitCfProperties>,
): Promise<Response>;

The relevant generation seams are:

  1. FunctionTraits<R (This::*)(Args...)> retains return and arguments but not the C++ owner used by RTTI:
    template <typename R, typename... Args>
    struct FunctionTraits<R(Args...)> {
    using ReturnType = R;
    using ArgsTuple = std::tuple<Args...>;
    };
    template <typename R, typename... Args>
    struct FunctionTraits<R (*)(Args...)> {
    using ReturnType = R;
    using ArgsTuple = std::tuple<Args...>;
    };
    template <typename This, typename R, typename... Args>
    struct FunctionTraits<R (This::*)(Args...)> {
    using ReturnType = R;
    using ArgsTuple = std::tuple<Args...>;
    };
    template <typename T>
    struct FunctionTraits<T, std::void_t<decltype(&T::operator())>>
    : public FunctionTraits<decltype(&T::operator())> {};
    template <typename This, typename R, typename... Args>
    struct FunctionTraits<R (This::*)(Args...) const> {
    using ReturnType = R;
    using ArgsTuple = std::tuple<Args...>;
    };
    template <typename Configuration, typename Tuple>
  2. createMethodPartial(fullyQualifiedParentName, method) already receives the parent type name, but emits only method.args and the result:
    method: Method
    ): [ts.Modifier[], string, ts.ParameterDeclaration[], ts.TypeNode] {
    const modifiers: ts.Modifier[] = [];
    if (method.static) {
    modifiers.push(f.createToken(ts.SyntaxKind.StaticKeyword));
    }
    const name = method.name;
    const params = createParamDeclarationNodes(
    fullyQualifiedParentName,
    name,
    method.args.toArray(),
    /* forMethod */ true
    );
    const result = createTypeNode(method.returnType);
    return [modifiers, name, params, result];
    }
  3. maybeExtractGlobalNode() converts ServiceWorkerGlobalScope members into top-level functions while copying the parameter list unchanged:
    export function maybeExtractGlobalNode(
    ctx: ts.TransformationContext,
    node: ts.Node,
    modifiers?: readonly ts.ModifierLike[]
    ): ts.Statement | undefined {
    if (
    (ts.isMethodSignature(node) || ts.isMethodDeclaration(node)) &&
    ts.isIdentifier(node.name)
    ) {
    return ctx.factory.createFunctionDeclaration(
    modifiers,
    /* asteriskToken */ undefined,
    node.name,
    node.typeParameters,
    node.parameters,
    node.type,
    /* body */ undefined
    );
    }
    if (
    (ts.isPropertySignature(node) ||
    ts.isPropertyDeclaration(node) ||
    ts.isGetAccessorDeclaration(node)) &&
    ts.isIdentifier(node.name)
    ) {
    assert(node.type !== undefined);
    // Don't create global nodes for nested types, they'll already be there
    if (!ts.isTypeQueryNode(node.type)) {
    const varDeclaration = ctx.factory.createVariableDeclaration(
    node.name,
    /* exclamationToken */ undefined,
    node.type
    );
    const varDeclarationList = ctx.factory.createVariableDeclarationList(
    [varDeclaration],
    ts.NodeFlags.Const // Use `const` instead of `var`
    );
    return ctx.factory.createVariableStatement(modifiers, varDeclarationList);
    }
    }
    }
    function createGlobalScopeVisitor(

Bounded direction

For ordinary non-static JSG methods, TypeScript can model the runtime requirement directly:

interface Crypto {
  getRandomValues<T extends ArrayBufferView>(
    this: Crypto,
    array: T,
  ): T;
}

For extracted Worker globals, one union receiver can preserve bare/null/global calls while rejecting an unrelated holder in TypeScript 5.8.3:

declare function fetch(
  this: ServiceWorkerGlobalScope | null | void,
  input: RequestInfo | URL,
  init?: RequestInit<RequestInitCfProperties>,
): Promise<Response>;

A Cloudflare-specific caveat remains: because the global transform emits free declarations rather than making typeof globalThis exactly ServiceWorkerGlobalScope, a complete generated-output fixture may need a designated-global alias or another receiver member to preserve globalThis.fetch(...) without creating recursive output.

Suggested first prototype:

  • add this: OwningType to generated ordinary non-static JSG methods;
  • leave static methods receiver-free;
  • widen only the extracted global copy;
  • add snapshot and tsc fixtures covering bare calls, actual global calls, detached calls, .call(null), .call(undefined), .call({}), and unrelated holders;
  • measure compatibility across representative APIs before applying the change broadly.

A generator-only prototype may be possible because the parent type name is already available. RTTI receiver metadata could follow later if another consumer needs it.

Questions

  1. Is a generator-only prototype using the existing parent type context an acceptable first step?
  2. For inherited methods, should the explicit receiver use the declaring type or generated leaf type?
  3. Which non-static JSG methods intentionally permit receiver-independent invocation?
  4. What generated type should represent the legal Worker-global receiver without recursive globalThis output?
  5. Should this source-diagnostic change follow the normal generated-types release path or an opt-in transition?

Out of scope

  • relaxing JSG/V8 receiver enforcement;
  • changing Fetch or Web IDL semantics;
  • solving every receiver-erasure path in TypeScript;
  • requiring nominal brands across all generated Worker interfaces.

Related receiver precedent:

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions