Skip to content

tsc -b: incremental build keeps a declaration that names a removed re-export, and passes a program a clean build rejects #64591

Description

🔎 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:

  1. tsc -b app. It fails, as it must.
  2. Change lib/src/server.ts to export {}; and run tsc -b app again.
  3. 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

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