Skip to content

Cross-module interface dispatch: methods called on an interface-typed parameter return undefined unless the consumer module value-imports the impl class #240

Closed

Description

@proggeramlug

Perry: 0.5.359 native (macOS aarch64). Discovered while integrating the now-fixed #233 + #235 into our @perryts/mongodb driver bench harness — the bench's workload (in bench/src/workload.ts) takes a Driver interface as a parameter and calls driver.findOne(...) on it. After #235 was fixed, the call started silently returning undefined and the impl's method body was never invoked. Bisected to module layout.

The trigger: when a consumer function lives in a module that only type-imports the interface (no value-side import of any class implementing it), Perry compiles the call site without any dispatch entry, and await d.method() resolves to undefined. The impl's method body is never entered. Adding any value-side import of an implementing class to the consumer module — even an unused export const _ref = Impl — restores correct dispatch.

Bun 1.3.12 and Node 25.8.0 both run the broken repro correctly (the impl method is invoked, the awaited value is the real result).

Repro — 4 files

driver.ts:

export interface Driver {
    findOne(db: string, col: string, filter: any): Promise<any>;
    close(): Promise<void>;
}

impl.ts:

import { MongoClient } from "@perryts/mongodb";
import type { Driver } from "./driver.ts";

export class Impl implements Driver {
    client: MongoClient | null = null;
    async findOne(db: string, col: string, filter: any): Promise<any> {
        console.log('  [Impl.findOne] CALLED');
        if (this.client === null) {
            this.client = await MongoClient.connect("mongodb://127.0.0.1:27017");
        }
        return this.client.db(db).collection(col).findOne(filter);
    }
    async close(): Promise<void> {
        if (this.client !== null) { this.client.close(); this.client = null; }
    }
}

workload.ts — only type-imports Driver:

import type { Driver } from "./driver.ts";

export async function consume(d: Driver): Promise<void> {
    const m = await d.findOne('sample_mflix', 'movies', { title: 'The Great Train Robbery' });
    console.log('consume: m typeof=' + typeof m + ' isUndef=' + (m === undefined));
    await d.close();
}

main.ts:

import { Impl } from "./impl.ts";
import { consume } from "./workload.ts";

async function main() { await consume(new Impl()); }
main().catch((e: any) => console.error('ERR:', e));

Actual (Perry 0.5.359)

consume: m typeof=undefined isUndef=true

The [Impl.findOne] CALLED log never appears — the impl's method body is not invoked.

Expected (Bun 1.3.12, Node 25.8.0 both produce this)

  [Impl.findOne] CALLED
consume: m typeof=object isUndef=false

The fix that restores correct dispatch

Add any value-side import of Impl to workload.ts. Even an unused re-export works:

import type { Driver } from "./driver.ts";
import { Impl } from "./impl.ts";
export const _ref: typeof Impl = Impl;   // <-- this single line is enough

export async function consume(d: Driver): Promise<void> {
    // unchanged
    const m = await d.findOne('sample_mflix', 'movies', { title: 'The Great Train Robbery' });
    console.log('consume: m typeof=' + typeof m + ' isUndef=' + (m === undefined));
    await d.close();
}

With that single value-import, the impl method is invoked correctly. The fix needs to be a class that implements the interface — importing an unrelated class (e.g., class Unrelated { x: number = 0 }) does not help.

Bisection

Same Driver + Impl source, varied module layout:

Layout Result
All in one file (interface + impl + consumer + main) works
Interface in own file, impl + consumer + main in second file works
3 files: interface, impl, main (consumer in main) works
4 files: interface, impl, consumer, main (matches our bench) broken
Same 4 files, but workload.ts value-imports Impl works
Same 4 files, but workload.ts value-imports an unrelated class broken

So the trigger is specifically: a consumer module in a separate file from main, where the consumer module only knows the interface as a type.

Likely cause

Looks like Perry's per-module method-table generation only emits dispatch entries for an interface in modules that value-import a class implementing it. Modules that only type-import the interface get no dispatch table for it, so calls compile to a no-op resolution that yields undefined.

Notes

Activity

  1. added a commit that references this issue on Apr 28, 2026
  2. added a commit that references this issue on Apr 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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