Skip to content

Content mappers: moduleSuffixes probes card.foo.web instead of card.web.foo for a registered extension #64548

Description

@leonidaz

With "moduleSuffixes": [".web", ""], a fully specified import of a content-mapper extension puts the suffix after the extension: import "./card.foo" probes card.foo.web instead of card.web.foo, so it resolves to card.foo. For a built-in extension, import "./target.ts" probes target.web.ts and resolves to it.

Demo Repo

https://gist.github.com/leonidaz/a68ed721ae38e2a90eada142074c24f9

git clone https://gist.github.com/leonidaz/a68ed721ae38e2a90eada142074c24f9.git content-mapper-module-suffixes-repro
cd content-mapper-module-suffixes-repro
pnpm install --ignore-scripts
pnpm run setup
pnpm run check           # exit 2: TS2322, './card.foo' resolved to card.foo
pnpm run check:standard  # exit 0: './target.ts' resolved to target.web.ts

The mapper is the 47-line identity mapper from #64546: .foo files contain TypeScript, returned unchanged as .ts with an identity span map. setup.mjs only writes the local mapper manifest under node_modules. pnpm run check is exactly tsc --runExternalCode --pretty false -p tsconfig.json.

Which of the following problems are you reporting?

The module specifier resolves, but to the wrong file

Demonstrate the defect described above with a code sample.

card.foo exports platform = "default", card.web.foo exports platform = "web":

// main.ts
import { platform } from "./card.foo";
const check: "web" = platform; // TS2322: Type '"default"' is not assignable to type '"web"'.

Run tsc --showConfig and paste its output here

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

--showConfig omits contentMappers in this nightly; tsconfig.json registers identity-foo-mapper for [".foo"].

Run tsc --traceResolution and paste its output here

======== Resolving module './card.foo' 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.foo', target file types: TypeScript, JavaScript, Declaration, JSON.
File name '/repro/card.foo' has a '.foo' extension - stripping it.
File '/repro/card.foo.web' does not exist.
File '/repro/card.foo' exists - use it as a name resolution result.
======== Module name './card.foo' was successfully resolved to '/repro/card.foo'. ========
main.ts(2,7): error TS2322: Type '"default"' is not assignable to type '"web"'.

The control, import "./target.ts" from standard.ts with target.ts and target.web.ts:

======== Resolving module './target.ts' from '/repro/standard.ts'. ========
Explicitly specified module resolution kind: 'Bundler'.
Resolving in CJS mode with conditions 'import', 'types'.
Loading module as file / folder, candidate module location '/repro/target.ts', target file types: TypeScript, JavaScript, Declaration, JSON.
File name '/repro/target.ts' has a '.ts' extension - stripping it.
File '/repro/target.web.ts' exists - use it as a name resolution result.
======== Module name './target.ts' was successfully resolved to '/repro/target.web.ts'. ========

Paste the package.json of the importing module, if it exists

{
  "name": "typescript-content-mapper-module-suffixes-repro",
  "private": true,
  "type": "module",
  "scripts": {
    "setup": "node setup.mjs",
    "check": "tsc --runExternalCode --pretty false -p tsconfig.json",
    "check:standard": "tsc --runExternalCode --pretty false -p tsconfig.standard.json",
    "trace": "tsc --runExternalCode --pretty false -p tsconfig.json --traceResolution"
  },
  "devDependencies": {
    "typescript": "7.1.0-dev.20260929.1"
  }
}

Paste the package.json of the target module, if it exists

Same package: card.foo, card.web.foo, target.ts and target.web.ts sit next to the importers.

Any other comments can go here

  • Reproduced with typescript@7.1.0-dev.20260929.1 (typescript@next on 2026-09-29, gitHead 0681ef7fa3a2378ccf49645b6d5b7463bdca74bb), macOS 26.6.2 x64, Node.js 24.18.0, pnpm 10.33.4. Verified from a fresh clone of the gist.

  • There is no runtime step in the demo: the expected placement is TypeScript's own, shown by the .ts control and by the moduleSuffixes docs (foo.ios.ts, not foo.ts.ios).

  • Cause, from main at b85298b: tryFile takes the extension from tspath.TryGetExtensionFromPath, which only knows built-in extensions. For card.foo it returns "", so the suffix lands after the whole file name. With this change, a local build of main passes the demo, the control is unchanged, and go test ./internal/module/... ./internal/modulespecifiers/... ./internal/contentmapper/... passes:

    --- a/tsc/internal/module/resolver.go
    +++ b/tsc/internal/module/resolver.go
    @@ -1578,6 +1578,11 @@ func (r *resolutionState) tryFile(fileName string) (string, bool) {
     	}
     
     	ext := tspath.TryGetExtensionFromPath(fileName)
    +	if ext == "" {
    +		// Content-mapper extensions are not built in: keep the suffix in front of them
    +		// (card.web.foo, not card.foo.web).
    +		ext = tspath.GetLongestExtensionFromPath(fileName, r.resolver.extraExtensions, false)
    +	}
     	fileNameNoExtension := tspath.RemoveExtension(fileName, ext)
     	for _, suffix := range r.compilerOptions.ModuleSuffixes {
     		path := fileNameNoExtension + suffix + ext
  • Found while looking into Content mappers: registered extensions are not probed for extensionless imports in bundler mode #64546. Platform variants such as Card.web.tsrx / Card.ios.tsrx in React Native-style projects are the use case.

The reproduction and report were prepared with Claude Code; the commands above were run and the results verified.

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