A library builds green, publishes, and a consumer reports that the package does not compile:

node_modules/@scope/my-library/dist/index.d.ts:31:44 - error TS2304:
Cannot find name 'InternalCacheEntry'.

The declaration references a type that no longer exists. Your own build never noticed, because skipLibCheck told the compiler not to look at any .d.ts file — including the ones it had just written.


Root Cause

skipLibCheck is documented as skipping type-checking of declaration files, and people read that as “my dependencies’ declarations”. The compiler makes no such distinction: any file with a .d.ts extension is skipped, wherever it came from.

For a library that is exactly the wrong scope. Your dependencies’ declarations are somebody else’s problem and frequently unfixable; your own emitted declarations are the artefact you are about to publish, and they are entirely your problem. The option cannot distinguish the two.


Minimal Reproduction

// src/cache.ts
interface InternalCacheEntry { value: string; expiresAt: number; }
export function getEntry(key: string) {
  return cache.get(key) as InternalCacheEntry | undefined;   // inferred return type
}
// src/index.ts
export { getEntry } from "./cache.js";

The inferred return type mentions InternalCacheEntry, which is not exported, so the emitted declaration references a name that is not visible from outside:

// dist/index.d.ts — as emitted
export declare function getEntry(key: string): InternalCacheEntry | undefined;
npx tsc --noEmit                       # with skipLibCheck: true
echo "exit: $?"
exit: 0

A clean build, and a package that no consumer can compile against.


What the Flag Covers

What skipLibCheck skips The option applies to all declaration files. Dependency declarations are usually unfixable and slow to check, which is the reason people enable it. Your own emitted declarations are fixable and are the artefact you publish, which is why skipping them is costly. One flag, two very different populations of file dependency declarations thousands of files errors you often cannot fix slow to check on every build skipping these is the point of the flag your emitted declarations a handful of files errors you can always fix fast to check on their own skipping these ships the bug to every consumer

Step-by-Step Fix

1. Check the emitted declarations in isolation

The fix is not to disable the flag globally but to run a second, narrow check over the output — which is fast, because there are only a few files.

{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "noEmit": true,
    "skipLibCheck": false,
    "types": []
  },
  "include": ["dist/**/*.d.ts", "dist/**/*.d.cts"]
}
npx tsc -p tsconfig.dts-check.json
dist/index.d.ts:31:44 - error TS2304: Cannot find name 'InternalCacheEntry'.
Found 1 error.

The error your everyday build could not see, in a check that takes under a second.

2. Fix the leak at its source

- interface InternalCacheEntry { value: string; expiresAt: number; }
- export function getEntry(key: string) {
-   return cache.get(key) as InternalCacheEntry | undefined;
- }
+ export interface Entry { value: string; expiresAt: number; }
+ export function getEntry(key: string): Entry | undefined {
+   const hit = cache.get(key);
+   return hit && { value: hit.v, expiresAt: hit.exp };
+ }

Annotating the return type explicitly is the durable fix: inference at an API boundary is what pulled the internal name into the declarations in the first place.

3. Wire the check into the release path

{
  "scripts": {
    "build": "tsup",
    "check:dts": "tsc -p tsconfig.dts-check.json",
    "check": "tsc --noEmit && npm run check:dts && publint && attw --pack .",
    "prepublishOnly": "npm run build && npm run check"
  }
}
Two checks, two configurations The everyday type-check keeps skipLibCheck enabled so builds stay fast. A separate narrow check runs with it disabled over only the emitted declaration files, which is fast because there are few of them. Keep the speed; stop shipping the blind spot tsc --noEmit (everyday) skipLibCheck: true checks src/, ignores all .d.ts runs on every save tsc -p tsconfig.dts-check.json skipLibCheck: false checks only dist/*.d.ts runs before publish

HAZARD PREVENTION

Symptom: The declaration check reports errors originating inside a dependency’s types rather than your own.

Root cause: Your declarations reference a dependency’s types, so checking them pulls that dependency’s declarations into the program — reintroducing the problem the flag was avoiding.

Fix: Set "types": [] in the check configuration to exclude ambient type packages, and treat any remaining dependency error as a genuine incompatibility worth reporting upstream rather than suppressing.

4. Run an unskipped full check periodically

A weekly or pre-release job with skipLibCheck: false across the whole program surfaces dependency incompatibilities before a consumer finds them. Keep it out of the fast path, and treat its findings as upstream issues rather than local suppressions.


Verification

# the narrow check passes
npx tsc -p tsconfig.dts-check.json && echo "declarations are internally valid"

# and the published types resolve for every consumer mode
npx attw --pack .

# a consumer compiling against the tarball succeeds
d=$(mktemp -d); npm pack --silent | xargs -I{} mv {} "$d/"
cd "$d" && npm init -y >/dev/null && npm install ./*.tgz typescript --silent
printf 'import { getEntry } from "@scope/my-library";\nconst e = getEntry("k");\n' > probe.ts
npx tsc --noEmit --strict --module nodenext --moduleResolution nodenext probe.ts && echo "consumer compiles"

Expected: declarations are internally valid, a clean attw table, and consumer compiles.


Edge Cases / Gotchas

  • Declaration bundlers can mask and create the problem. Flattening declarations into one file resolves some internal references and can inline a dependency’s types, changing which errors exist at all.
  • skipLibCheck also skips your own .d.ts source files. Hand-written ambient declarations in src/ are skipped too, so a typo in a global augmentation goes unnoticed.
  • The check needs the same lib as the build. Running it with a narrower lib produces false errors about globals your published types legitimately assume.
  • Monorepo project references complicate the scope. Checking one package’s declarations pulls in its referenced siblings’ declarations; run the check per package rather than at the root.
  • A clean check is not a guarantee of correctness. It proves the declarations are valid TypeScript, not that they describe the runtime accurately — that is what the type-level tests and attw cover.

The wider lesson generalises past this one flag: any setting that speeds up a build by checking less will, eventually, be the reason something ships broken. That is not an argument against such settings — build speed is a real cost too — but it is an argument for knowing precisely what each one stops checking, and for adding a narrow, slow check over exactly that surface before the artefact leaves your machine.


Frequently Asked Questions

Does skipLibCheck skip my own declaration files too?

Yes — it applies to every .d.ts regardless of origin, including the ones your build just emitted.

Why is skipLibCheck so widely recommended then?

Because checking every dependency’s declarations is slow and often surfaces errors you cannot fix. Turning it off wholesale replaces a speed problem with a triage problem.

What is the middle ground?

Keep it enabled for everyday development and run a separate narrow check over your emitted declarations, which is fast and covers the part you can actually fix.

Does arethetypeswrong catch what skipLibCheck hides?

Partly. It verifies resolution and format matching, not internal validity — a file can resolve perfectly and still reference a name that does not exist.

How often does this actually produce a broken release?

Most often after a refactor that removes or renames an internal type still referenced by a public signature, and after switching declaration tooling.



↑ Back to Optimizing tsconfig.json for Library Distribution