Right, so you’ve got your TypeScript compiling. The code runs. You’re feeling pretty good. But let me ask you a question you didn’t want to hear: are you sure you don’t have any type errors? I mean, really sure? If you’re just running tsc to emit JavaScript, you’re living on a prayer. That command will happily spit out .js files even if your types are a complete dumpster fire, as long as the syntax is vaguely correct. It’s the equivalent of saying “Well, the engine fell out, but look, the radio still works!”

This is why tsc --noEmit is your new best friend in CI. It’s the pedantic, hyper-vigilant security guard for your codebase. It does the type checking without bothering with the whole “generating output” thing. Its only job is to find problems and yell about them. And in a Continuous Integration pipeline, that’s exactly the kind of unyielding scrutiny you need.

Why --noEmit is a Non-Negotiable CI Step

Think of your CI pipeline as a series of gates. A commit doesn’t get to merge until it passes through all of them. tsc --noEmit is your first, most important gate. It answers one question: “Is this code even semantically valid TypeScript?” If it’s not, why on earth would you waste CPU cycles running linters, tests, or a deployment? You’re just going to fail later, but with more steps and more wasted time. Failing fast on type errors is the professional move. It saves resources and gives the developer immediate, unambiguous feedback: “Fix your types. Then we’ll talk.”

The Basic Command and Its Nuances

The base command is simple, but like a talented diva, it has some demands.

# The classic. It uses your tsconfig.json by default.
npx tsc --noEmit

# Be explicit. It's good practice, especially in CI where paths can be weird.
npx tsc --project ./tsconfig.json --noEmit

But here’s where people get tripped up: scope. By default, tsc will use your tsconfig.json, which might not include all the files you think it does. If you’ve used "exclude" or a sparse "include", you might be checking a happy little island of perfection while the mainland is burning down. In CI, you almost always want to check the entire project. A good practice is to have a dedicated tsconfig.ci.json that extends your base config but removes any excludes and ensures everything is included.

// tsconfig.ci.json
{
  "extends": "./tsconfig.json",
  "exclude": [] // Override and exclude nothing. Check it all.
}

Then, run it with:

npx tsc --project ./tsconfig.ci.json --noEmit

Integrating with Your CI Platform

The integration is straightforward because the command is your oracle; it exits with code 0 on success and >0 on failure. Every CI system on earth understands this.

Here’s a realistic example for a GitHub Actions workflow:

# .github/workflows/typecheck.yml
name: Type Check

on: [push, pull_request]

jobs:
  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci # Use 'ci' for a deterministic install in CI
      - run: npx tsc --project ./tsconfig.ci.json --noEmit

See? No fancy actions needed. It’s just npx tsc. If there’s a type error, the step fails, the build is marked as failed, and everyone knows about it. Beautiful.

Performance: Because You’re Not Made of Time

On a large project, the type checker can start to feel sluggish. The TypeScript team knows this, and they’ve given us a glorious tool: --incremental. This flag (often already enabled in your tsconfig via "incremental": true) tells the compiler to save information about the state of the program in a .tsbuildinfo file. On subsequent runs, it can skip checking files that haven’t changed, which is a massive speed boost.

In CI, however, you’re usually building on a fresh runner every time. There’s no .tsbuildinfo file from a previous run! So you might think --incremental is useless. Ah, but you can cache it.

# In your GitHub Actions step, add a cache step:
- name: Cache TypeScript incremental data
  uses: actions/cache@v4
  with:
    path: .tsbuildinfo # Or wherever your tsconfig outputs it
    key: ${{ runner.os }}-ts-incremental-${{ hashFiles('**/tsconfig.ci.json') }}
    restore-keys: |
      ${{ runner.os }}-ts-incremental-

This will restore the .tsbuildinfo file from a previous run, allowing the compiler to truly work incrementally and shave precious seconds off your CI time. It’s a small configuration win that feels like a major victory.

The Pitfalls and The “Wait, What?” Moments

  1. skipLibCheck: A Deal with the Devil. You might have "skipLibCheck": true in your tsconfig to speed things up. This is usually fine… until it’s catastrophically not. It skips type checking of your node_modules dependencies. If one of those libs has a bad type definition, tsc --noEmit will miss it. For CI, consider setting "skipLibCheck": false to be absolutely thorough. It’s slower, but safe.

  2. The Phantom d.ts Files. Remember, tsc follows your tsconfig. If you’re generating declaration files (.d.ts) with "declaration": true, --noEmit will surprisingly still emit those declaration files! It’s the one exception to the “no emit” rule. This is, frankly, a bit absurd. If you absolutely want zero file system changes, you need to also add --emitDeclarationOnly false.

So, the full, pedantic, “I-want-zero-surprises” command is:

npx tsc --project ./tsconfig.ci.json --noEmit --emitDeclarationOnly false

It’s verbose. It’s overkill for most. But it’s precise. And in the world of CI, precision is what keeps the ship from sinking. Now go configure it. Your brilliant-but-sloppy future self will thank your diligent current self when the build fails before it gets to production.