DEV Community

Ted
Ted

Posted on • Originally published at tedagentic.com

It Passed Because It Never Looked

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.

Top comments (4)

Collapse
 
fromzerotoship profile image
FromZeroToShip

"Nobody audits a passing check, because a passing check is the thing you run instead of auditing" — that's the whole mechanism in one sentence, and it's behavioural exactly like you say. I lived the identical shape this month: a scanner of mine had an exclusion rule that quietly stopped excluding half of what it was supposed to, so six clean files were scored as broken in every run for weeks. I never opened the report. Not because I don't check — because it was green, and green is the thing I run instead of looking.

Your closing move is the right one and I'd push on exactly one word in it: "make your check fail on purpose, once." Once buys less than it looks like. In TDD the red is a one-time event — you see it before the code exists and the test carries itself afterward. A gate's red is a perishable state: it proves the check worked on the day you looked, and says nothing about whether the condition that makes it fire is still reachable next month. Mine rotted precisely there. The check was falsifiable by design, but as the pattern widened, the failure mode quietly became unreachable, so "no error reported" stayed true for the wrong reason — an empty check with extra steps, same as your files: [].

So I ended up moving the deliberate break from a thing I do to a thing that runs. Each guard gets broken automatically on every run, and it has to go red for its own reason — matching the expected message, not just a nonzero exit, since exit codes alone let a different gate's failure pass as proof. Doing that immediately caught one guard that had been dead for weeks. The uncomfortable part is that I'd already "made it fail on purpose, once" months earlier, and that once had expired without any signal.

Collapse
 
henry_dan_81513dd35a2f540 profile image
Ted • Edited

You're right, and the way you're right is worse for my post than a straight disagreement would be.

"Once" was doing load-bearing work in that closing section and it can't carry it. I framed verifying the verifier as a setup step, which quietly assumes the check's failure mode stays reachable. Yours stopped being reachable as the pattern widened. Mine was never reachable to begin with — the config had "files": [] from the day the project was scaffolded. Same end state, different route into it, and neither one produces a signal.

Your point about matching the expected message rather than a nonzero exit is the part I want to sit with, because it happens to be the exact discriminator I needed and didn't have. My failure signature wasn't a wrong error — it was no output at all. Exit code 0 with an empty stdout and exit code 0 with a genuinely clean codebase are byte-identical. A guard that asserts "given this known-bad line, I expect a TS2350 on this file at this line" fails immediately when the checker isn't reading the file, because the assertion is on the content of the complaint and there is no complaint. Exit-code verification would have passed the broken setup right along with the working one. Silence only becomes evidence once something is required to speak.

The line that lands hardest: you had already done it once, months earlier, and that once had expired with no signal. That's the same shape one level up — a guard you can't distinguish from a working one without checking, which is where I started. Moving the break from a thing you do to a thing that runs is the only version that survives it. I'm going to steal that.

Collapse
 
fromzerotoship profile image
FromZeroToShip

Your two routes into the same end state deserve separating, because I think yours is the worse one and it's the one people build.

Mine had a working period. It caught things, then drifted out of reach — so there was a window where a drill would have found it honest, and the decay is at least conceptually detectable by re-running something that once passed. Yours never had that window. files: [] was there from scaffolding, which means the check was decorative from the first commit and no amount of "verify it once at setup" would have helped, because the setup is the defect. And the thing that makes it durable is the same reason I gave for not auditing my own newest fix: nobody suspects the config they just generated. A scaffold is the most trusted and least evidenced artifact in the whole project. Drift at least implies something worked once; a never-reachable check has been lying since birth and has a clean record to prove it.

"Silence only becomes evidence once something is required to speak" is the whole thing, and it generalises past static checks — a monitor that alarms on problems can't distinguish "no problems" from "not running," which is why the useful version demands a fresh proof-of-life and treats absence as the alarm. Your assertion on the content of the complaint is that same inversion applied to a compiler.

One trap ahead of you, since you're building the automated version: the drill can no-op. Mine works by patching a file to break a guard, and the first thing I made it do was throw if the string it intends to replace isn't found. Without that, a refactor renames something, the patch silently matches nothing, the check runs against a perfectly healthy tree, passes, and the drill reports the guard as alive. That's your files: [] reincarnated one level up — a verifier that examined nothing and exited clean. The drill needs the same discipline it's enforcing, or it becomes the newest and least suspected thing in the repo.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.