Tags typescripttscCIshellverification
All posts

typescript

My TypeScript check was silent, not passing

I judged TypeScript by grepping tsc output for a string it never prints. It stayed silent every time, and I read silence as success. Six real errors were hiding.

August 15, 2026 4 min

This was my type check:

npx tsc --noEmit | grep "error TS"

No output meant it passed. I read it that way several times in one session, and I wrote it into commit messages that way too.

All of those reports were false.

Dropping the filter surfaced six TypeScript errors

What made me suspicious had nothing to do with TypeScript. Code I was sure I had added was not on screen, and when I reopened the file, the place it should have been was untouched.

So I ran the compiler without the filter:

npx tsc --noEmit -p . --pretty false

Six errors. None of them had ever appeared before.

The grep failed because the compiler defaults to pretty output, which is formatted for humans. With colour codes and line breaks in play, the literal substring error TS is not present in the form I was searching for. With --pretty false you get the machine-readable single-line format, and the same grep matches.

Failure and silence looked identical

The real problem is not the grep syntax. It is the shape of the check.

When the code is clean, the command prints nothing. When the check is structurally unable to match, the command also prints nothing. Those two states are indistinguishable on screen.

So I was never observing “passed”. I was observing “said nothing” and interpreting it as passed. Nothing in the output could have corrected me.

I have hit this shape before in the same repository, with a build tool that reported a cache hit and restored zero files. That one also printed success while doing nothing at all. A success message and a success are different objects, and I have now learned that twice.

One error shipped five times

One of the six was a field added to the wrong type. The same identifier appeared twice in the file, and my edit landed on a function parameter instead of the interface.

It was committed and went out through five over-the-air updates.

Runtime was unaffected. The bundler strips types, so that declaration does not exist at runtime and no user hit anything.

The damage was to the reporting. For five releases I said “type check passes” and it was not true. If a genuinely breaking type error had appeared in that window, the same command would have hidden it just as thoroughly. The bug that shipped was harmless by luck, not by design, and luck is not a property I want my release process to depend on.

Checking the exit code has its own trap

There are two ways to fix this: switch to the machine-readable format, or stop reading output at all and read the exit code.

The exit code route has a second trap:

npx tsc --noEmit -p . | head -5; echo $?

$? here is the exit status of head, not of the compiler. A pipeline reports the status of its last command, so adding head to shorten the output silently changes what you are testing. This one is nastier than the grep, because it looks like a formatting decision rather than a semantic one.

What I do now is capture the output first and read the status at the same moment:

out=$(npx tsc --noEmit -p . --pretty false 2>&1); code=$?
echo "$out" | head -5
echo "tsc exit=$code"

set -o pipefail works too in bash. Either way the point is the same: do not let the value you display change the value you judge by.

Verifying the verifier

Since this, whenever I write a new check command, I break something on purpose once and confirm the check complains. If I introduce a known failure and the check stays quiet, then the check was never doing anything.

It costs about thirty seconds, and what it catches is not a thirty second mistake. Skipping it is how I ended up reporting a clean type check across five releases.

This is not specific to type checking. Any tool where you decide pass or fail by searching for a string carries the same risk. The absence of the string might mean success, or it might mean that string is never emitted in the form you are looking for.

I hit a sibling of this in the same session. I was editing source files with a Python script that does string replacement, and when the anchor no longer matches, it does not raise. It just does nothing. The script exits successfully and the file is unchanged. Now I grep for the result after every replacement, because a silent no-op and a completed edit look the same from the outside too.

What I would look for in someone else’s setup

If I were reviewing a repository today, the question I would ask about every check in the pipeline is not “does it pass” but “has anyone ever seen it fail”.

A check that has never once produced a red result is not evidence of clean code. It is an unverified claim about a tool. The two look the same in a green dashboard, and the dashboard is usually the only thing anyone reads.

That reframing is the whole lesson for me. I had treated the absence of complaints as information, when it was the absence of information.

Scope

Observed on 2026-08-14 and 2026-08-15 in one TypeScript 6.0.3 project. After switching to --pretty false, the same source produced six errors where the grep had been returning zero.

I did not investigate which compiler version made pretty output the default, and I have not audited my other check commands one by one for the same flaw. What I verified is that my command was lying, and what fixed it.

Previous Searching for a study planner returns three different things