It Passed Because It Never Looked

작성자

카테고리:

← 피드로
DEV Community · Ted · 2026-07-27 개발(SW)

Ted

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

Make your check fail on purpose. 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.

I first wrote that as a setup step: do it once when you create the project, and again if you inherit a repository or change build tooling. FromZeroToShip pushed back on the word once, and their counterexample is sharper than my own.

Their scanner had an exclusion rule that quietly stopped excluding what it was meant to. Six files were scored wrong for weeks and nobody opened the report, because it was green. They had already run the deliberate-break test months earlier and it had passed — the check was genuinely falsifiable on the day they tested it. Then the pattern it matched widened, the failure condition stopped being reachable, and “no errors” stayed true for the wrong reason.

That is my ending reached by a different road. Mine was never reachable. Theirs stopped being reachable. Neither transition emits anything at the moment it happens, so a one-time proof has an expiry date you cannot read from the outside.

Which means the deliberate break should not be a habit. It should be a job. Break each guard on every run, and require it to go red before its verdict counts.

And require it to go red for its own reason. A nonzero exit is not enough — that only says something failed, not that this check found this fault. My own failure signature was not a wrong answer, it was no answer: exit code 0 with empty output, byte-identical to exit code 0 on a genuinely clean codebase. An assertion on the content of the complaint — given this broken line, I expect TS2350 in this file — separates them immediately, because an empty check has nothing to say. Silence only becomes evidence once something is required to speak.

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.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다