Why skipLibCheck Hides Broken Published Types
skipLibCheck speeds up builds by skipping every .d.ts — including your own output. See what it conceals, and how to keep the speed without shipping broken declarations.
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
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"
}
}
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.
skipLibCheckalso skips your own.d.tssource files. Hand-written ambient declarations insrc/are skipped too, so a typo in a global augmentation goes unnoticed.- The check needs the same
libas the build. Running it with a narrowerlibproduces 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
attwcover.
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.
Related
- Optimizing tsconfig.json for Library Distribution — the configuration this option belongs to.
- Declaration File Generation and Type Stripping — treating declarations as a reviewed public API.
- Checking Types with are-the-types-wrong — the complementary resolution check.