Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 065 / 100

tsc as a type checker: a practical guide for developers

Let fast tools compile; let tsc verify.

Expona Admin6 min read

The Real Role of tsc in Your Pipeline

When you're shipping TypeScript across multiple platforms—web, mobile, backend—every second counts. Your bundler strips types in milliseconds. Your runtime does the same. But verification? That's a different beast. This is where tsc as a type checker becomes your safety net. Instead of letting type errors slip into production, you run tsc --noEmit in CI to catch them early, fail the pipeline deliberately, and keep your codebase honest.

I've watched teams spend weeks debugging runtime errors that a stricter type-checking phase would have caught in CI. The payoff is immediate: fewer production incidents, faster onboarding for new developers, and confidence that your types actually mean something.

Bundlers and Runtimes Strip Types Fast

Let's be clear: bundlers like esbuild, swc, and Webpack don't perform deep type checking. They transpile TypeScript to JavaScript by stripping annotations and comments. The same goes for Node.js and browser runtimes—they execute JavaScript, not TypeScript. This is by design. These tools are optimized for speed, not verification.

esbuild, for instance, can transpile a 10,000-line TypeScript codebase in under 100ms because it's not walking your type graph or validating generic constraints. It just removes the type syntax and ships it.

typescript
// Input: TypeScript with types
interface User {
  id: number;
  name: string;
  email?: string;
}

function createUser(user: User): User {
  return user;
}

// After transpilation: pure JavaScript
function createUser(user) {
  return user;
}

Your code runs perfectly in development. Your app feels snappy in build pipelines. But a subtle type error—passing a string where a number is expected, forgetting a required field, using a union type incorrectly—won't stop the build. The type annotations disappear, and JavaScript is forgiving. That's the gap we're filling with tsc --noEmit.

tsc --noEmit Checks Types in CI

The --noEmit flag tells TypeScript to skip code generation and only perform type checking. It reads your source files, builds the type graph, and reports any violations. The output? No .js files, no transpiled artifacts—just a pass or fail on types.

This is the workhorse of type safety in CI/CD pipelines. When you wire it into your pipeline, every pull request is validated against your strictest type rules before it merges.

typescript
// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noEmit": true,
    "skipLibCheck": false,
    "resolveJsonModule": true,
    "declaration": false,
    "module": "esnext",
    "target": "es2020"
  },
  "include": ["src/**/*.ts", "src/**/*.tsx"]
}

From the command line:

typescript
$ tsc --noEmit
src/services/authService.ts(42,15): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
src/components/UserForm.tsx(18,5): error TS2322: Type 'null' is not assignable to type 'React.ReactNode'.
Found 2 errors.

Your build fails. The developer fixes it. No guessing, no surprises at runtime. On a recent project handling ride-hailing integrations, catching type mismatches in the dispatch logic at CI time saved us from a critical payment bug in production.

Incremental Builds Speed It Up

Running full type checking on large codebases can slow down your CI. That's where incremental compilation enters. TypeScript caches type information from previous runs, so the next invocation only re-checks changed files and their dependents.

Enable it with incremental: true in your tsconfig.json:

typescript
// tsconfig.json
{
  "compilerOptions": {
    "incremental": true,
    "tsBuildInfoFile": ".tsbuildinfo",
    "strict": true,
    "noEmit": true
  }
}

The first run takes 15 seconds. Subsequent runs on the same files? Under a second. This is huge when your CI runs on every commit.

In practice, I've reduced type-checking time from 40 seconds to 3 seconds by enabling incremental builds and storing the .tsbuildinfo cache between CI runs. Most CI platforms (GitHub Actions, GitLab CI, CircleCI) support artifact caching, so you can persist the build info:

typescript
// GitHub Actions example
- name: Cache TypeScript build info
  uses: actions/cache@v3
  with:
    path: |
      .tsbuildinfo
      node_modules/.tsbuildinfo
    key: ${{ runner.os }}-tsc-${{ github.sha }}
    restore-keys: |
      ${{ runner.os }}-tsc-

- name: Type check
  run: tsc --noEmit

On a SaaS platform I maintain with 50+ services, this cut CI feedback time significantly. Developers get green checkmarks faster, and the team ships more confidently.

Fail the Pipeline on Type Errors

Here's the non-negotiable part: type errors must block merges. If your CI passes despite type failures, your type system is theater. People ignore it, complexity creeps in, and you're back to debugging runtime errors in production.

Set up your CI to fail on any tsc exit code other than 0:

typescript
#!/bin/bash
# scripts/typecheck.sh

set -e  # Exit on first non-zero code

tsc --noEmit

if [ $? -ne 0 ]; then
  echo "Type checking failed. Fix types before merging."
  exit 1
fi

echo "Type check passed."

Or more directly in your CI config:

typescript
// .github/workflows/ci.yml example
name: CI

on: [push, pull_request]

jobs:
  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
      - run: npm ci
      - run: tsc --noEmit
        # If tsc exits with non-zero, the workflow stops here

The key is consistency. Every merge request must pass type checking. No exceptions, no "we'll fix it later." I've seen teams soften this rule and watch type safety erode in weeks. Enforce it once, maintain it forever.

Common Mistakes

Skipping skipLibCheck or setting it incorrectly: If your dependencies have type errors, `tsc` will report them by default. Set `skipLibCheck: true` to skip checking third-party type definitions unless you're auditing dependencies specifically.

Not caching incremental builds in CI: Running full type checks on every commit wastes time. Use CI caching for .tsbuildinfo to get the speed benefits of incremental builds.

Using tsc for both compilation and type checking: Don't. Use your bundler (esbuild, Webpack, Vite) for production builds. Use tsc --noEmit only for CI validation. Mixing them causes confusion about which tool is responsible for what.

Relying on IDE type checking alone: Your IDE is helpful, but it's not the source of truth. Some developers disable strict mode in their local settings while committing code that fails in CI. Make the CI rule non-negotiable.

Not configuring strict: true: Loose mode (default) allows implicit `any` and other lenient behaviors. If you're using TypeScript, commit to strict: true from the start or enable it early. Retrofitting is painful but worth it.

Key Takeaways

  • Bundlers and runtimes transpile fast by ignoring types. Type verification is a separate step.
  • Run tsc --noEmit in CI as your type safety gate. It catches errors bundlers miss.
  • Enable incremental compilation and cache .tsbuildinfo in CI to keep type checking fast.
  • Fail the pipeline on type errors unconditionally. Type safety without enforcement is just code comments.
  • tsc is a type checker first, a compiler second. Use the right tool for each job: bundlers for building, tsc for verifying.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles