I ran the typechecker. It passed. I ran the build. It passed. I pushed, and a page that carries a large share of the site's traffic started rendering an error screen instead of content.
The error was not subtle:
error TS2350: Only a void function can be called with the 'new' keyword.
Enter fullscreen mode Exit fullscreen mode
That is about as blunt as TypeScript gets. It is not an edge case, not a version-specific quirk, not something that needs strict mode to surface. It is the compiler saying you tried to new something that cannot be constructed.
So why did four consecutive clean runs of npx tsc --noEmit tell me everything was fine?
The command examined nothing
The project's tsconfig.json looks like this:
{
"compilerOptions": { "...": "..." },
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
Enter fullscreen mode Exit fullscreen mode
This is a solution-style config. It contains no source files of its own — "files": [] says so explicitly. It exists to point at two other configs, one for application code and one for build tooling. The pattern is normal and sensible; it lets you apply different rules to your app than to your config files.
The catch is what plain tsc does with it. Given a config with project references, tsc does not descend into the referenced projects. It typechecks what the config directly includes, which here is nothing at all, finds no errors in that empty set, and exits zero.
The command that actually checks the application code is:
npx tsc -p tsconfig.app.json --noEmit
Enter fullscreen mode Exit fullscreen mode
Run that against the same commit and both errors appear immediately, with file and line numbers.
This is not an exotic misconfiguration I inflicted on myself. It is the shape that scaffolding tools generate by default for React and TypeScript projects. A very large number of repositories have this exact layout, and in every one of them the reflexive npx tsc --noEmit is a no-op that looks like a pass.
The build does not save you either. Modern bundlers transpile TypeScript by stripping the types — they never check them. A clean build tells you the syntax parsed. It says nothing about whether the types are coherent.
The bug itself was a dead import
The underlying mistake is almost funny in isolation. The file imported a set of icons from an icon library, and the list included one named Map:
import {
MapPin, ExternalLink, Info, CheckCircle2,
Store, ListChecks, ArrowRight, Map, ShieldCheck,
} from "lucide-react";
Enter fullscreen mode Exit fullscreen mode
That import shadows the global Map constructor for the entire module. So when I later wrote what I thought was an ordinary lookup table:
const websiteBySlug = new Map(
rows.filter(r => r.website).map(r => [r.slug, r.website])
);
Enter fullscreen mode Exit fullscreen mode
…I was not constructing a Map. I was trying to call new on a React component that renders an SVG. Hence: only a void function can be called with the new keyword.
The detail that stuck with me: that icon was never rendered anywhere in the file. It was a leftover from an earlier edit, an unused import with no visible effect on the page. Its only remaining function in the codebase was to shadow a global constructor and lie in wait. Deleting the line was the entire fix.
If you use an icon library, it is worth knowing which of its exports collide with JavaScript globals. Map, Set, Image, Text, Menu, Link, History, Option, Search, Frame — all common icon names, all real globals.
Green and zero are not the same kind of wrong
I have written before about a dashboard reading zero when nothing was counting — a null result produced by the instrument rather than the world.
A false green is worse, and the reason is behavioural rather than technical.
A zero is uncomfortable. It contradicts what you hoped for, so it invites a second look. Even when you accept it, you accept it as a finding, and findings get revisited.
A green result closes the question. It is the outcome you wanted, arriving in the form you expected, and it terminates the investigation by design. That is its whole purpose. Nobody audits a passing check, because a passing check is the thing you run instead of auditing.
So the two possible meanings of my clean exit code —
- everything was examined and nothing was wrong
- nothing was examined
— are indistinguishable from outside, and only one of them prompts any further curiosity. I got the second one four times and read it as the first, because that is what green is for.
The tell was there
There was evidence, and I walked past it repeatedly.
npx tsc --noEmit produced no output whatsoever on a codebase of several hundred routes. Not a warning, not a note, not a summary line. Just an instant return to the prompt.
That should have registered. A typechecker that finishes instantly on a large project and says nothing has either done something impressive or done nothing at all, and the second is far more likely. I read the silence as cleanliness. Silence and success produce identical terminal output, which is precisely the problem.
Verify the verifier
The habit worth building is small and takes about a minute: make your check fail on purpose, once.
Introduce a deliberate error — a genuinely broken line — and confirm the tool actually complains. If it stays green, you have not discovered a robust codebase. You have discovered that your check does not run.
Do it when you set the project up. Do it again if you inherit a repository, change build tooling, or move to CI, because all three can quietly relocate what gets checked without changing the command you type.
The generalisation goes past TypeScript. Any verification step can degrade into theatre: a test suite where a path filter stopped matching, a linter whose config no longer resolves, an integration check pointed at a stale environment. In each case the pipeline goes green, the ritual is observed, and nothing is being verified.
An unrun check is not neutral. It is worse than having no check at all, because no check leaves you appropriately nervous, and a broken one sells you confidence you have not earned.
I shipped a crash to a production page with four green checkmarks behind me. The compiler had the answer the entire time. I just never asked it anything.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.