Skip to content

tsconfig include silently drops Foo.tsx when foo.ts exists, on a case-insensitive filesystem #64098

Description

🔎 Search Terms

include, wildcard, extension priority, case-insensitive file system, hasFileWithHigherPriorityExtension, .ts vs .tsx, file silently excluded from program

🕗 Version & Regression Information

Reproduces on 6.0.3 and on 7.0.2 (native). This is long-standing behaviour, not a recent regression — I did not bisect it.

⏯ Playground Link

Not applicable: the bug is in tsconfig file selection, so it needs two files on disk.

💻 Code

Three files, no dependencies:

src/brand.ts

export const brand = "ok";

src/Brand.tsx — note the capital B

// A deliberate type error. If this file is in the program, tsc MUST report it.
export const oops: number = "not a number";

tsconfig.json

{
  "compilerOptions": { "noEmit": true, "strict": true, "jsx": "react-jsx", "types": [] },
  "include": ["src/**/*"]
}

Run tsc --showConfig -p tsconfig.json and tsc --noEmit -p tsconfig.json.

One-liner for the Linux side, if you are on a Mac:

docker run --rm -v "$PWD":/src:ro -w /app oven/bun:latest \
  sh -c 'cp -a /src/. /app/ && bun add typescript@7.0.2 >/dev/null 2>&1 && \
         ./node_modules/.bin/tsc --noEmit -p tsconfig.json'

🙁 Actual behavior

On a case-insensitive filesystem (macOS APFS, and Windows), src/Brand.tsx is dropped from the program with no diagnostic at all:

$ tsc --showConfig -p tsconfig.json
    "files": [
        "./src/brand.ts"
    ],
$ tsc --noEmit -p tsconfig.json
$ echo $?
0

On a case-sensitive filesystem, the same tree, same compiler, same tsconfig:

$ tsc --showConfig -p tsconfig.json
    "files": [
        "./src/Brand.tsx",
        "./src/brand.ts"
    ],
$ tsc --noEmit -p tsconfig.json
src/Brand.tsx(2,14): error TS2322: Type 'string' is not assignable to type 'number'.
$ echo $?      # 2 on 6.0.3, 1 on 7.0.2

Verified identical on 6.0.3 (JS) and 7.0.2 (native) — both compilers, both directions.

🙂 Expected behavior

src/brand.ts and src/Brand.tsx are two different files with two different names. The .ts > .tsx > .d.ts de-duplication is meant for files that differ only in extension (foo.ts vs foo.tsx), and it should not fire across a base-name case difference.

If the de-dup is intentional here, it should at least not be silent. Today a file simply vanishes from the build: tsc exits 0 and prints nothing, so nothing tells the author their source was never compiled.

Cause

getFileNames in src/compiler/commandLineParser.ts (TS 7: internal/tsoptions/tsconfigparsing.go) calls:

if (hasFileWithHigherPriorityExtension(file, literalFileMap, wildcardFileMap, supportedExtensions, keyMapper)) {
  continue;
}

and hasFileWithHigherPriorityExtension does:

const higherPriorityPath = keyMapper(changeExtension(file, ext));
if (literalFiles.has(higherPriorityPath) || wildcardFiles.has(higherPriorityPath)) {
  ...
  return true;
}

keyMapper is createGetCanonicalFileName(host.useCaseSensitiveFileNames), which case-folds on a case-insensitive host. So for file = /p/src/Brand.tsx and ext = ".ts":

changeExtension → /p/src/Brand.ts → keyMapper → /p/src/brand.ts

which is already in wildcardFileMap (from the real src/brand.ts), so Brand.tsx is skipped. The extension key is case-folded together with the base name, and the base name is what actually differs.

Notes

  • The file is not unresolvable on macOS. Adding import "./Brand.tsx" from another file in the program pulls it in and the error appears immediately. Only the include wildcard refuses it.
  • forceConsistentCasingInFileNames does not help: there is no import to check the casing of. The file at issue here is a bundler entry point with no importer in the TS program, which is exactly the shape that hides this.
  • Listing the file explicitly in "files" also works around it (literalFileMap is populated before the wildcard pass).

Why this hurt

A repo of mine had packages/templates/src/Brand.tsx (a Remotion render entry) sitting next to packages/templates/src/brand.ts (its config module). The .tsx had a real type error and had never been compiled on any developer machine — every local tsc was green. CI runs on Linux, where it was red. Because the file itself looked fine and the tooling versions were pinned and identical, the divergence got misdiagnosed for a full session as a generic-inference difference in a third-party library's .d.ts, and "fixed" by adding as unknown as casts to silence it. The actual bug was that half the team's compiler had never read the file.

Activity

  1. Shivang9983 commented on Aug 30, 2026

    @Shivang9983

    Hi Jomon Johnson (@jomonkj) Matt Sheehan (@sheehan) Ron Buckton (@rbuckton),

    I would love to work on this issue!

    Analysis & Proposed Fix:

    The root cause is clear: hasFileWithHigherPriorityExtension applies keyMapper (which case-folds on case-insensitive filesystems) over the entire path after changing the extension. As a result, extension priority de-duplication incorrectly triggers across different base names (Brand.tsx matching brand.ts).

    Proposed Implementation:

    1. Update hasFileWithHigherPriorityExtension in src/compiler/commandLineParser.ts (and TS 7 native parsing logic) so that case-folding/canonicalization is restricted to checking path/extension matching without erasing the case distinctions of the base filename.
    2. Ensure extension deduplication (.ts > .tsx > .d.ts) only matches files whose canonical base names are identical.
    3. Add tests verifying that brand.ts and Brand.tsx co-exist in program files when resolved via include wildcards on case-insensitive host environments.

    Could you please assign this issue to me? Thanks!

  2. RyanCavanaugh commented on Sep 4, 2026

    @RyanCavanaugh
    Member

    The above comment is wrong AI spam but is kind of a good start to a correct analysis.

    The problem is that our "higher priority" logic is really about making sure that we always choose a file's input over its output, e.g. if we see foo.ts, foo.d.ts, and foo.js on disk, we can reasonably assume that foo.ts is the correct file to load and the others should be ignored. The same goes if we have foo.d.ts and foo.js, or foo.ts and foo.js.

    But "priority" is the wrong concept here, what we really mean to do is "ignore this file if it's another file's output", so we really need two buckets, one for inputs (.ts, .tsx, .mts, etc) and one for outputs (.d.ts, .js, .jsx, etc). We should always prefer a file's input to its output but two files with the same non-extension name can still contribute to a program.

    Care should be taken to ensure that mixed ts/js compilations under allowJs behave correctly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

BugA bug in TypeScript

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions