tsc kept saying 0 errors — because it had stopped checking anything
@types/react quietly went missing from node_modules on a side project. tsc --noEmit kept running, kept exiting 0 — it just wasn't checking anything React-shaped anymore, and nothing about a green CI run said so.
Background
bopomofokids.com is a static React SPA I built
for my kid — no backend, no test framework beyond Playwright e2e and Vite’s
build. Its lint script is just tsc --noEmit; there’s no ESLint, no
separate type-coverage tool. That one command is the entire static-analysis
safety net for the project.
The tsconfig.json has neither strict nor noImplicitAny turned on — a
deliberate choice to keep a solo side project’s type checking lightweight
rather than importing a large ruleset for a codebase with one contributor.
That looseness is exactly what let this go unnoticed.
What actually happened
At some point @types/react and @types/react-dom went missing from
node_modules — not through any change to package.json, just an
environment where they hadn’t actually been installed. npm run lint kept
running. It kept exiting 0.
That’s the surprising part: a missing type package for a module you import everywhere should be the loudest possible failure. It wasn’t, because of how TypeScript resolves an import it can’t find a declaration for.
Concretely, this quietly disabled:
// useCallback's generic inference — the callback's param/return types
// stop being inferred or checked; TypeScript just accepts whatever's passed
const onSelect = useCallback((symbol: BopomofoItem) => { ... }, []);
// React.FC<Props> — prop type checking on every component
// with 'React' resolved to any, the whole generic is inert
const QuizCard: React.FC<QuizCardProps> = ({ item, onPick }) => { ... };
// JSX itself — a typo'd prop, a nonexistent one, a wrong type,
// none of it gets flagged once the JSX namespace is untyped
what `any` silently swallows
A typo’d prop name, a component given the wrong shape of data, a hook used
with the wrong argument type — all of these are exactly the class of bug
tsc --noEmit exists to catch, and all of them stopped being caught, with no
error, warning, or exit-code change to say so.
Why skipLibCheck doesn’t explain this away
This project’s tsconfig.json has skipLibCheck: true, which sounds
related but isn’t the cause. skipLibCheck only skips checking the
contents of .d.ts files that do exist — it doesn’t fabricate type
declarations for a package that’s missing entirely. The failure here is one
level earlier: there was no .d.ts to skip checking, so TypeScript fell back
to its default answer for an import with no available types, which is any.
How something like this gets noticed at all
Nothing about a CI run flags this. “0 errors” prints identically whether the
codebase is genuinely clean or the type checker has quietly stopped looking
at anything React-shaped. The only tell is behavioral: tsc feeling
strangely untroubled by a mistake that should obviously fail — a prop that
doesn’t exist on a component, a hook called with the wrong type — is the
signal to check the checker’s own inputs, not the code it’s supposedly
checking.
Takeaways
0 errors is not evidence of a clean codebase — it’s evidence that
whatever the checker was actually looking at had no errors. When the thing
being looked at silently shrinks to nothing, the exit code doesn’t change
to tell you.
With noImplicitAny off, an unresolved import doesn’t error — it becomes
any, which the compiler treats as fully legal. The safety a type checker
provides is only as strong as its own dependencies being present; a
lightweight tsconfig makes that dependency invisible when it’s missing,
rather than loud.
It’s easy to conflate “lib check is off” with “missing types don’t matter.”
skipLibCheck only applies to .d.ts files that exist. A package with no
installed types at all isn’t in that category — it just resolves to any,
silently, one step before skipLibCheck would even apply.