🔎 Search Terms
tsc -b incremental stale declaration, stale d.ts, re-export removed, declaration emit module specifier, getAlternativeContainingModules, build mode false negative, tsbuildinfo
🕗 Version & Regression Information
- This is the behavior in every version I tried, and I reviewed the FAQ for entries about incremental builds and project references
Tried: 6.0.3, 7.0.2, 7.1.0-dev.20261002.1.
⏯ Playground Link
No response (needs tsc -b and two builds)
💻 Code
A composite project lib and a project app that references it.
// lib/src/deep/tags.ts
export interface CommonErrors {
readonly tag: "common";
}
// lib/src/deep/boundary.ts
import type { CommonErrors } from "./tags";
export const boundary = <T>(value: T): T & { readonly middleware: CommonErrors } => ({
...value,
middleware: { tag: "common" },
});
// lib/src/server.ts
export type { CommonErrors } from "./deep/tags";
// lib/src/feed/api.ts (imports deep/boundary only)
import { boundary } from "../deep/boundary";
export const feed = boundary({ name: "feed" });
// app/src/index.ts (skipLibCheck; must not compile: tag is a string)
import { feed } from "../../lib/src/feed/api";
export const wrong: number = feed.middleware.tag;
Steps:
tsc -b app. It fails, as it must.
- Change
lib/src/server.ts to export {}; and run tsc -b app again.
- Delete the outputs and the build info, and run
tsc -b app again.
Script that writes the project in a temporary directory and runs the three steps (argument: the tsc command)
#!/bin/sh
# usage: sh repro-3-stale-declaration.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 lib/src/deep lib/src/feed app/src
cat >lib/tsconfig.json <<'JSON'
{
"compilerOptions": {
"composite": true,
"emitDeclarationOnly": true,
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"rootDir": "src",
"outDir": "out"
},
"include": ["src"]
}
JSON
cat >lib/src/deep/tags.ts <<'TS'
export interface CommonErrors {
readonly tag: "common";
}
TS
cat >lib/src/deep/boundary.ts <<'TS'
import type { CommonErrors } from "./tags";
export const boundary = <T>(value: T): T & { readonly middleware: CommonErrors } => ({
...value,
middleware: { tag: "common" },
});
TS
cat >lib/src/feed/api.ts <<'TS'
import { boundary } from "../deep/boundary";
export const feed = boundary({ name: "feed" });
TS
echo 'export type { CommonErrors } from "./deep/tags";' >lib/src/server.ts
cat >app/tsconfig.json <<'JSON'
{
"compilerOptions": {
"noEmit": true,
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true
},
"include": ["src"],
"references": [{ "path": "../lib" }]
}
JSON
cat >app/src/index.ts <<'TS'
import { feed } from "../../lib/src/feed/api";
export const wrong: number = feed.middleware.tag;
TS
named() { grep -o 'import("[^"]*")[A-Za-z.]*' lib/out/feed/api.d.ts; }
echo "app assigns a string to a number, so every build must fail."
echo "1. clean build"
$tsc -b app
echo " exit code: $? feed/api.d.ts names: $(named)"
sleep 1
echo 'export {};' >lib/src/server.ts
echo "2. server.ts no longer exports CommonErrors; incremental build"
$tsc -b app
echo " exit code: $? feed/api.d.ts names: $(named)"
rm -rf lib/out lib/tsconfig.tsbuildinfo app/tsconfig.tsbuildinfo
echo "3. the same source; clean build"
$tsc -b app
echo " exit code: $? feed/api.d.ts names: $(named)"
🙁 Actual behavior
1. clean build exit code: 1 feed/api.d.ts names: import("../server").CommonErrors
2. server.ts no longer exports CommonErrors; incremental build exit code: 0 feed/api.d.ts names: import("../server").CommonErrors
3. the same source; clean build exit code: 1 feed/api.d.ts names: import("../deep/tags").CommonErrors
Step 2 passes a program that step 3, with the same source, rejects.
The declaration of feed/api.ts has to name CommonErrors, which that file does not import. The emitter picks the shortest path that exports the name, which is ../server. When server.ts stops exporting it, feed/api.d.ts is not written again, because feed/api.ts does not import server.ts. The declaration then names an export that does not exist, and app reads it as any.
🙂 Expected behavior
Step 2 fails like step 3: feed/api.d.ts is written again with import("../deep/tags").CommonErrors, or the stale name is reported.
Additional information about the issue
🔎 Search Terms
tsc -b incremental stale declaration, stale d.ts, re-export removed, declaration emit module specifier, getAlternativeContainingModules, build mode false negative, tsbuildinfo
🕗 Version & Regression Information
Tried: 6.0.3, 7.0.2, 7.1.0-dev.20261002.1.
⏯ Playground Link
No response (needs
tsc -band two builds)💻 Code
A composite project
liband a projectappthat references it.Steps:
tsc -b app. It fails, as it must.lib/src/server.tstoexport {};and runtsc -b appagain.tsc -b appagain.Script that writes the project in a temporary directory and runs the three steps (argument: the tsc command)
🙁 Actual behavior
Step 2 passes a program that step 3, with the same source, rejects.
The declaration of
feed/api.tshas to nameCommonErrors, which that file does not import. The emitter picks the shortest path that exports the name, which is../server. Whenserver.tsstops exporting it,feed/api.d.tsis not written again, becausefeed/api.tsdoes not importserver.ts. The declaration then names an export that does not exist, andappreads it asany.🙂 Expected behavior
Step 2 fails like step 3:
feed/api.d.tsis written again withimport("../deep/tags").CommonErrors, or the stale name is reported.Additional information about the issue
--incrementalretains stale diagnostics when augmenting a re-exported interface #64386 (stale incremental diagnostics with a re-exported interface) and tsc --build false negative through export star facade project #63579 (a false negative oftsc --buildthrough anexport *project, fixed).anywhere it had a type.