Skip to content

checkJs skips .mjs/.cjs beside a .d.mts/.d.cts, but not .js beside a .d.ts #64312

Open

Description

🔎 Search Terms

checkJs, allowJs, .d.mts, .d.cts, declaration file adjacent to implementation, file silently dropped from program, extension priority

🕗 Version & Regression Information

Reproduces identically on 5.9.3, 6.0.3, and 7.1.0-dev.20260916.1. Long-standing, not a regression.

⏯ Playground Link

Not applicable: needs two sibling files on disk plus moduleResolution: nodenext.

💻 Code

Three directories, identical but for extensions. Same tsconfig.json in each; package.json has "type": "module" only for the .mjs case.

index.js / index.mjs / index.cjs:

export const n = 1;

const bad = null;
bad.a.b.c();

index.d.ts / index.d.mts / index.d.cts:

export declare const n: number;
{
	"compilerOptions": {
		"target": "esnext",
		"module": "nodenext",
		"moduleResolution": "nodenext",
		"allowJs": true,
		"checkJs": true,
		"strict": true,
		"noEmit": true
	}
}

🙁 Actual behavior

index.js   + index.d.ts    ->  error TS18047: 'bad' is possibly 'null'.
index.mjs  + index.d.mts   ->  (no output)
index.cjs  + index.d.cts   ->  (no output)

The same authoring relationship — a hand-written declaration file beside its implementation — produces opposite outcomes based only on the file extension.

In the .mjs/.cjs cases the implementation is not merely unchecked, it is absent from the program: --listFiles reports only index.d.mts. In the .js case --listFiles reports both index.d.ts and index.js.

🙂 Expected behavior

The three rows should agree. Either all of them check the implementation, or none do.

I'd expect the .js row to be the correct one — the declaration file describes the module for consumers, and checkJs still checks the implementation — but consistency matters more than which way it goes.

Additional information

#47796 reported the .mjs/.mts variant and was closed as "Working as Intended", on the rationale that the JS file is a build artifact of the TS file, and that "the compiler only automatically pulls in one copy of a file with the same basename (the most TS-y one)".

That rationale doesn't extend to this case:

  • A .d.mts is not something a .mjs is emitted from, so the build-artifact framing doesn't apply. .d.ts + .js is the same relationship, and there the compiler does pull in both.
  • Neither does the one-copy-per-basename rule, for the same reason — index.js and index.d.ts are both in the program.

The failure is silent, which is what makes it expensive: a package shipping .mjs/.cjs with adjacent hand-written declarations gets no checking of its implementation at all, while every sibling module without a declaration file is checked normally. tsc exits 0 and CI is green. I found this only by deliberately breaking a file and noticing the build still passed. Per #47796, VS Code does report these errors, so the editor and CI silently disagree.

#64098 is the same silent-drop shape from wildcard include (hasFileWithHigherPriorityExtension), if the two are related.

So, concretely:

  1. Make the three rows consistent, or
  2. if this is intended, document which sibling extensions suppress checking, and emit a diagnostic when an input file is dropped from the program for this reason.

Activity

  1. added
    BugA bug in TypeScript
    Needs ProposalThis issue needs a plan that clarifies the finer details of how it could be implemented.
    on Sep 23, 2026
  2. sbrilenko commented on Sep 24, 2026

    @sbrilenko

    I traced this down to the exact code responsible, in both implementations, and it's a narrower bug than "declaration files suppress their implementation" in general — it's a hardcoded carve-out that only lists two extensions.

    Root cause

    Wildcard (include) file discovery de-duplicates same-basename files by extension priority in hasFileWithHigherPriorityExtension (src/compiler/commandLineParser.ts, and identically ported to internal/tsoptions/tsconfigparsing.go in the native compiler). The relevant branch:

    if (literalFiles.has(higherPriorityPath) || wildcardFiles.has(higherPriorityPath)) {
        if (ext === Extension.Dts && (fileExtensionIs(file, Extension.Js) || fileExtensionIs(file, Extension.Jsx))) {
            // LEGACY BEHAVIOR: An off-by-one bug somewhere in the extension priority system for wildcard module loading allowed declaration
            // files to be loaded alongside their js(x) counterparts. We regard this as generally undesirable, but retain the behavior to
            // prevent breakage.
            continue;
        }
        return true;
    }

    This continue (i.e. "don't drop this file, keep both") is the entire reason .js beside .d.ts behaves the way it does — and the condition only checks Extension.Js/Extension.Jsx against Extension.Dts. It was never extended to Extension.Mjs/Extension.Cjs against Extension.Dmts/Extension.Dcts, so those pairs fall through to return true — "a higher-priority file exists, drop this one" — exactly the silent exclusion reported here. Confirmed empirically: passing the same two files via files: [...] instead of include bypasses getFileNamesFromConfigSpecs entirely and both files are checked, in every extension combination. The bug is specifically in wildcard/glob discovery, not in createProgram's general root-file handling.

    Why this isn't #47796

    #47796 (closed Working as Intended) is .mjs beside .mts — a real hand-written implementation in two module dialects, where picking the more TS-y file and ignoring the other is defensible (you don't want to compile two independent implementations of the same module). This issue is .mjs beside .d.mts — a hand-authored declaration only, the direct .mjs/.d.mts analog of the case the comment above already exists to support for .js/.d.ts. The maintainers' own comment frames the .js/.d.ts case as legacy/undesirable-but-kept, not as an intentional design distinct from .mjs/.d.mts — nothing in it argues the two should diverge.

    Proposed fix

    Extend the existing condition symmetrically, in both commandLineParser.ts and tsconfigparsing.go. A table of (declaration extension, implementation extension) pairs reads more clearly than a chain of mixed &&/||, and makes the parallel between the four pairs explicit:

    const declarationCarveOuts: [Extension, Extension][] = [
        [Extension.Dts, Extension.Js],
        [Extension.Dts, Extension.Jsx],
        [Extension.Dmts, Extension.Mjs],
        [Extension.Dcts, Extension.Cjs],
    ];
    if (declarationCarveOuts.some(([dts, impl]) => ext === dts && fileExtensionIs(file, impl))) {
        continue;
    }

    This is a minimal, symmetry-restoring change — it doesn't touch the .mjs/.mts (#47796) case at all, since that pairing was never routed through this carve-out to begin with. Happy to open a PR with this plus a tsc --explainFiles-based regression test if that's the direction maintainers want, given the "Needs Proposal" label — wanted to confirm the approach first since this is shared, security-adjacent-in-spirit (silent exclusion from checking) logic.

  3. hardikkaurani commented on Sep 29, 2026

    @hardikkaurani

    This issue is being addressed by #64527.

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

    BugA bug in TypeScriptNeeds ProposalThis issue needs a plan that clarifies the finer details of how it could be implemented.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions