Skip to content

Content mappers: registered extensions are not probed for extensionless imports in bundler mode #64546

Description

@leonidaz

With a custom .foo extension registered in contentMappers, native tsc resolves import "./card.foo", but reports TS2307 for import "./card" under moduleResolution: "bundler". The registered file is already in the program's files list. A bundler configured to resolve .foo handles the same extensionless import successfully.

Demo repo

Cloneable reproduction: https://gist.github.com/leonidaz/ca03b7192f722681735822341f50f741

The mapper is a small standalone Node.js program that returns the .foo source unchanged as TypeScript with an identity span map. No TSRX, Volar, framework, tsserver plugin, or generated declaration shim is involved. Type checking invokes native tsc directly; esbuild is used only for the separate runtime control.

git clone https://gist.github.com/leonidaz/ca03b7192f722681735822341f50f741.git content-mapper-extensionless-repro
cd content-mapper-extensionless-repro
pnpm install --ignore-scripts
pnpm run setup
pnpm exec tsc --version

# Fails: TS2307 for './card', exit 2.
pnpm run check

# Both controls pass, exit 0.
pnpm run check:explicit
pnpm run check:standard

# Same TS2307 even with both allow flags enabled.
pnpm exec tsc --runExternalCode --allowArbitraryExtensions --allowImportingTsExtensions --pretty false -p tsconfig.json

# Configured esbuild bundles the extensionless import; Node prints 42.
pnpm run runtime

setup.mjs only writes a local mapper package manifest under node_modules, pointing at the included mapper.mjs using the current Node executable and checkout path. pnpm run check is exactly tsc --runExternalCode --pretty false -p tsconfig.json.

Version and regression information

  • Reproduced with typescript@7.1.0-dev.20260929.1, the version advertised by typescript@next when checked on 2026-09-29.
  • pnpm exec tsc --version reports Version 7.1.0-dev.20260929.1.
  • npm reports gitHead: 0681ef7fa3a2378ccf49645b6d5b7463bdca74bb for this nightly and its platform package.
  • Tested on macOS 26.6.2 x64, Node.js 24.18.0, pnpm 10.33.4; runtime control uses esbuild 0.28.2.
  • Not claiming a regression from an earlier working content-mapper version. The failure and both passing TypeScript controls were also verified from a fresh clone of the linked reproduction.

Which problem is being reported?

The module specifier resolves with the configured runtime build, but not during TypeScript checking under moduleResolution: "bundler".

Code

// card.foo
export const value = 42 as const;
// main.ts
import { value } from "./card";

The full importer adds const check: 42 = value; console.log(check);. The explicit-import control changes only the import target to "./card.foo". A separate control imports a regular .ts file without its extension and passes.

The actual mapper's transform response is simply:

const text = request.params.content;
result = {
  text,
  extension: ".ts",
  mappings: [[0, text.length, 0, text.length, 0]],
};

The complete JSON-RPC transport and manifest setup are included in the reproduction.

Configuration

tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noEmit": true,
    "types": []
  },
  "contentMappers": [
    {
      "package": "identity-foo-mapper",
      "extensions": [
        ".foo"
      ]
    }
  ],
  "files": [
    "main.ts",
    "card.foo"
  ]
}

The runtime-control command is:

pnpm exec esbuild main.ts --bundle --platform=node --format=esm --loader:.foo=ts --resolve-extensions=.foo,.ts,.js --outfile=out.mjs
node out.mjs
# 42

This report concerns the configured bundler environment. It does not request relaxing Node ESM's explicit-extension rules or imply that a content mapper configures the runtime loader.

Actual behavior

main.ts(1,23): error TS2307: Cannot find module './card' or its corresponding type declarations.
Case Result
Registered/included card.foo, import ./card TS2307, exit 2
Same mapper and file, import ./card.foo Pass, exit 0
Regular standard-target.ts, import ./standard-target Pass, exit 0
Import ./card with allowArbitraryExtensions and allowImportingTsExtensions also enabled Same TS2307
Bundler configured with .foo in its extension search list Bundles and runs; prints 42

All checking commands use --runExternalCode. Omitting it correctly produces the separate diagnostic that content mappers require that flag; enabling it does not address the extensionless lookup.

Expected behavior

A way for registered content-mapper extensions to participate in extensionless relative lookup in resolution modes that support it, so the checker can match a bundler configured to resolve those extensions. In this fixture there are no competing card.ts, card.tsx, card.d.ts, or JavaScript files, so the request does not depend on deciding precedence between colliding files.

If explicit-only lookup for mapper extensions is intentional, please treat this as a request for supported/configurable extensionless lookup rather than a regression report. Registering the extension plus selecting bundler resolution currently does not provide it.

tsc --showConfig

Actual output of pnpm exec tsc --runExternalCode --showConfig -p tsconfig.json:

{
    "compilerOptions": {
        "module": "esnext",
        "moduleResolution": "bundler",
        "noEmit": true,
        "strict": true,
        "target": "es2022",
        "types": []
    },
    "files": [
        "./main.ts",
        "./card.foo"
    ]
}

This nightly's --showConfig omits contentMappers; the source config above contains the registration, and the passing explicit-import control verifies that the mapper is loaded.

tsc --traceResolution

Actual output of pnpm exec tsc --runExternalCode --pretty false -p tsconfig.json --traceResolution, with the temporary checkout root normalized to /repro:

======== Resolving module './card' from '/repro/main.ts'. ========
Explicitly specified module resolution kind: 'Bundler'.
Resolving in CJS mode with conditions 'import', 'types'.
Loading module as file / folder, candidate module location '/repro/card', target file types: TypeScript, JavaScript, Declaration, JSON.
File '/repro/card.ts' does not exist.
File '/repro/card.tsx' does not exist.
File '/repro/card.d.ts' does not exist.
File '/repro/card.js' does not exist.
File '/repro/card.jsx' does not exist.
Directory '/repro/card' does not exist, skipping all lookups in it.
======== Module name './card' was not resolved. ========
main.ts(1,23): error TS2307: Cannot find module './card' or its corresponding type declarations.

There is no probe for card.foo.

Importing and target package manifests

Both source files belong to the same reproduction package:

{
  "name": "typescript-content-mapper-extensionless-repro",
  "private": true,
  "type": "module",
  "scripts": {
    "setup": "node setup.mjs",
    "check": "tsc --runExternalCode --pretty false -p tsconfig.json",
    "check:explicit": "tsc --runExternalCode --pretty false -p tsconfig.explicit.json",
    "check:standard": "tsc --runExternalCode --pretty false -p tsconfig.standard.json",
    "trace": "tsc --runExternalCode --pretty false -p tsconfig.json --traceResolution",
    "runtime": "esbuild main.ts --bundle --platform=node --format=esm --loader:.foo=ts --resolve-extensions=.foo,.ts,.js --outfile=out.mjs && node out.mjs"
  },
  "devDependencies": {
    "esbuild": "0.28.2",
    "typescript": "7.1.0-dev.20260929.1"
  }
}

The separate local mapper manifest is generated by the included setup.mjs; it declares typescript.contentMapper.exec and compilerOptions: [].

Source reference and related work

At main-branch commit 299a555c3a91519552b471c5b8ce3eb4247ab044, tryAddingExtensions handles an empty originalExtension using the built-in TypeScript/JavaScript candidates. The subsequent default branch checks extraExtensions when an extension is explicitly present. That appears to explain the trace. This is source inspection of main, distinct from the nightly binary tested above.

I searched issues and open PRs in microsoft/TypeScript and the earlier microsoft/typescript-go repository for content mapper, contentMappers, extensionless, extraExtensions, and related module-resolution terms. I did not find an open PR implementing this lookup:

The reproduction and report were prepared with Codex. The reported compiler and runtime results were executed and verified, including from a clean clone of the reproduction.

Activity

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

    Working as IntendedThe behavior described is the intended behavior; this is not a bug

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions