Skip to content

Behavior difference: import * as x of an export = callable loses call signatures without esModuleInterop (TS2349) #4697

Description

@greg-flux

Behavior difference: import * as x of an export = callable loses its call signatures without esModuleInterop (TS2349)

🔎 Search terms

import star, export equals, export =, namespace import, not callable, TS2349, esModuleInterop, allowSyntheticDefaultImports, synthetic default, getSpreadType, resolveESModuleSymbol

🕗 Version & Regression Information

  • Reproduces on 7.0.0-dev.20260707.2 (and every earlier nightly tried).
  • Behavior differs from tsc (TypeScript 5.7).

⏯ Playground / Repro

tsc accepts, tsgo reports TS2349. No esModuleInterop.

mod.d.ts

declare const thing: {
  (x: string): number;
  prop: boolean;
};
export = thing;

use.ts

import * as thing from './mod';
const a: number = thing('hello');   // tsgo: TS2349 — This expression is not callable
const b: boolean = thing.prop;

tsconfig.json

{
  "compilerOptions": {
    "module": "commonjs",
    "allowSyntheticDefaultImports": true,
    "strict": false,
    "noEmit": true
  },
  "include": ["use.ts"]
}

🙁 Actual behavior (tsgo)

use.ts(2,19): error TS2349: This expression is not callable.
  Type '{ prop: boolean; default: { (x: string): number; prop: boolean; } }' has no call signatures.

tsgo synthesises a namespace type { prop; default } for the import * as
binding — it hoists the export = value to .default, spreads its properties,
and drops the call/construct signatures.

🙂 Expected behavior (tsc)

tsc produces no error — thing keeps its call signature and is callable. Only
when esModuleInterop: true (or an ESM→CJS reference) does tsc emit the same
TS2349. Real-world impact: any import * as request from 'supertest' /
import * as archiver from 'archiver' followed by request(...) / archiver(...)
fails under tsgo but is accepted by tsc — hundreds of errors across a normal
NestJS/e2e test suite that runs module: commonjs without esModuleInterop.

🌳 Root cause

resolveESModuleSymbol (internal/checker/checker.go) gates the synthetic-default
wrap on hasSignatures(typ) || hasDefault || isEsmCjsRef. tsc's
resolveESModuleSymbol gates it on getESModuleInterop(compilerOptions) || isEsmCjsRef
and only then checks for signatures/default. tsgo dropped the esModuleInterop
guard, so a callable export = is wrapped (and its signatures spread away by
getSpreadType) even when interop is off.

There is also no getESModuleInterop equivalent on CompilerOptions in tsgo, so
the implicit-true cases (module: node16/18/20/nodenext/preserve) aren't accounted
for at this call site.

✅ Suggested fix

Add CompilerOptions.GetESModuleInterop() mirroring tsc (explicit option, else
true for node16/18/20/nodenext/preserve), and gate the wrap on
GetESModuleInterop() || isEsmCjsRef with the hasSignatures || hasDefault check
moved inside. Verified against the local conformance suite (TestLocal) — passes,
with one cosmetic symbol-baseline shift (globalArrayAugmentation…:
foo.itemsitems, which is the un-wrapped, tsc-correct form). PR to follow.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Working As IntendedThis issue describes intentional behavior.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions