Skip to content

Declaration emit writes an import the file cannot resolve when the package has exports; no TS2883 #64590

Description

🔎 Search Terms

TS2883, TS2742, cannot be named without a reference, not portable, declaration emit module specifier, exports map, pnpm, import type cannot be resolved, skipLibCheck any

🕗 Version & Regression Information

  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about declaration emit and "cannot be named"

Tried: 6.0.3, 7.0.2, 7.1.0-dev.20261002.1.

⏯ Playground Link

No response (needs a node_modules layout)

💻 Code

A strict node_modules layout, as pnpm makes it. a depends on s, s depends on c, and a cannot resolve c. The package c has an exports map with a subpath.

node_modules/.store/c/node_modules/c/      package.json with "exports": { "./Thing": … }, dist/Thing.d.ts
packages/s/node_modules/c            ->    the store
packages/a/node_modules/s            ->    ../../s
packages/u/node_modules/a            ->    ../../a
// c/dist/Thing.d.ts
export interface Thing {
  readonly x: number;
}
export declare const create: () => Thing;

// packages/s/src/index.ts   (composite project)
import { create } from "c/Thing";
export const made = create();

// packages/a/src/index.ts   (composite project, references s)
import { made } from "s";
export const thing = { made };

// packages/u/src/index.ts   (references a, skipLibCheck)
import { thing } from "a";
export const wrong: string = thing.made.x;
Script that writes the project in a temporary directory and runs tsc -b (argument: the tsc command)
#!/bin/sh
# usage: sh repro-1-import-out-of-reach.sh [tsc command]
set -u
tsc=${1:-tsc}
cd "$(mktemp -d)" || exit 1
# The physical path: on macOS the temporary directory is behind a symbolic link.
cd "$(pwd -P)" || exit 1
mkdir -p node_modules/.store/c/node_modules/c/dist packages/s/src packages/s/node_modules \
  packages/a/src packages/a/node_modules packages/u/src packages/u/node_modules

cat >node_modules/.store/c/node_modules/c/package.json <<'JSON'
{
  "name": "c",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    "./Thing": { "types": "./dist/Thing.d.ts", "default": "./dist/Thing.js" }
  }
}
JSON
cat >node_modules/.store/c/node_modules/c/dist/Thing.d.ts <<'TS'
export interface Thing {
  readonly x: number;
}
export declare const create: () => Thing;
TS

ln -s ../../../node_modules/.store/c/node_modules/c packages/s/node_modules/c
ln -s ../../s packages/a/node_modules/s
ln -s ../../a packages/u/node_modules/a

library='{
  "compilerOptions": {
    "composite": true,
    "emitDeclarationOnly": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "strict": true,
    "rootDir": "src",
    "outDir": "out"
  },
  "include": ["src"]REFERENCES
}'

echo '{ "name": "s", "version": "1.0.0", "exports": { ".": "./src/index.ts" } }' >packages/s/package.json
printf '%s\n' "$library" | sed 's/REFERENCES//' >packages/s/tsconfig.json
cat >packages/s/src/index.ts <<'TS'
import { create } from "c/Thing";

export const made = create();
TS

echo '{ "name": "a", "version": "1.0.0", "exports": { ".": "./src/index.ts" } }' >packages/a/package.json
printf '%s\n' "$library" | sed 's#REFERENCES#,\n  "references": [{ "path": "../s" }]#' >packages/a/tsconfig.json
cat >packages/a/src/index.ts <<'TS'
import { made } from "s";

export const thing = { made };
TS

cat >packages/u/tsconfig.json <<'JSON'
{
  "compilerOptions": {
    "noEmit": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src"],
  "references": [{ "path": "../a" }]
}
JSON
cat >packages/u/src/index.ts <<'TS'
import { thing } from "a";

export const wrong: string = thing.made.x;
TS

echo "a depends on s; s depends on c; a cannot resolve c."
echo "u assigns a number to a string, so the build must fail."
$tsc -b packages/u
echo "exit code: $?"
echo "packages/a/out/index.d.ts:"
cat packages/a/out/index.d.ts

🙁 Actual behavior

tsc -b packages/u exits with 0. The declaration of a is:

export declare const thing: {
    made: import("c/Thing").Thing;
};

c/Thing cannot be resolved from packages/a. The consumer u reads Thing as any, so const wrong: string = thing.made.x is accepted although x is a number. skipLibCheck hides the failed import; with skipLibCheck: false the same build reports packages/a/out/index.d.ts(2,18): error TS2307: Cannot find module 'c/Thing'.

🙂 Expected behavior

TS2883 at thing in packages/a/src/index.ts. That is what all three versions report for the same layout when c has "types": "index.d.ts" and no exports map:

packages/a/src/index.ts(3,14): error TS2883: The inferred type of 'thing' cannot be named without a reference to 'Thing' from '../../s/node_modules/c'. This is likely not portable. A type annotation is necessary.

Additional information about the issue

  • In 7.0.2, tryGetModuleNameAsNodeModule (internal/modulespecifiers/specifiers.go, about line 786) returns the specifier as soon as it comes verbatim from the package's exports (if verbatimFromExports { return moduleFileToTry }). That return is before the check that the top-level node_modules directory is an ancestor of the importing file, which is the check that gives an empty result, and so TS2883, in the case with no exports map.
  • How we found it: a workspace package had import("@effect/sql-pg/PgClient").PgClient in a declaration and did not depend on @effect/sql-pg. Its consumers read a layer's requirements as any.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions