Dataslope logoDataslope

Static Analysis in Action

Explore what the TypeScript compiler actually proves and how it powers your development workflow.

You've written types, modeled domains, and structured architectures. But what does the TypeScript compiler actually do with all that information? What guarantees does it provide? And how does it power the editor experience that makes TypeScript so productive?

This chapter reveals the compiler's inner workings: how it catches bugs at compile time, enables refactoring with confidence, proves exhaustiveness, and drives the language service that powers autocomplete, go-to-definition, and instant feedback in your editor.


What the Compiler Proves

When you run tsc (the TypeScript compiler), it doesn't just transform .ts files into .js. It performs static analysis — reasoning about your code without running it — and proves (or disproves) properties:

  1. Type safety: Every value matches the types you've declared. No accidental string where a number is expected.
  2. Null safety: With strictNullChecks, you can't access .length on a value that might be null or undefined.
  3. Exhaustiveness: In discriminated unions, the compiler checks that you've handled every case.
  4. Structural compatibility: When you assign a value to a variable, the compiler proves the shapes match.

These proofs eliminate entire classes of bugs. Let's see them in action.


Type Safety: Catching Typos and Mismatches

Code Block
TypeScript 5.7

If you uncomment the last two lines, the compiler catches the type mismatch before you ever run the code. This prevents runtime errors like NaN from arithmetic on a string.


Null Safety with strictNullChecks

Without strictNullChecks, null and undefined are assignable to any type. This is a source of countless runtime crashes.

With strictNullChecks enabled (part of strict mode), the compiler forces you to check for null/undefined before accessing properties:

Code Block
TypeScript 5.7

The compiler tracks the control flow and narrows s from string | null to string after the null check. This is called control flow analysis.


Exhaustiveness Checking

When you have a discriminated union and switch on the discriminant, the compiler checks that you've handled every case. If you add a new variant and forget to handle it, the compiler catches it.

Code Block
TypeScript 5.7

The never type is a bottom type — a type with no values. If all cases are handled, shape in the default branch has type never, and the assignment succeeds. If you add a new variant (e.g., { kind: "triangle"; ... }) and don't handle it, shape won't be never, and the compiler errors.

Let's see what happens if we add a new shape:

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number }; // New!

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    default:
      const _exhaustive: never = shape; // Error: Type 'triangle' is not assignable to 'never'
      throw new Error(`Unhandled shape: ${_exhaustive}`);
  }
}

The compiler forces you to add a case "triangle" branch. This guarantees you won't forget to handle new cases.


Refactor Safety

One of TypeScript's superpowers is refactoring confidence. When you rename a type, function, or property, the compiler finds every usage and tells you what to update.

Let's see a refactor in action:

Code Block
TypeScript 5.7

Suppose you rename fullName to name. Every place that accesses .fullName becomes a compile error, forcing you to update it. No silent bugs, no missed spots. The compiler is your safety net.


The Compiler Pipeline

The TypeScript compiler is organized into phases:

  1. Scanner: Breaks source code into tokens (keywords, identifiers, operators).
  2. Parser: Builds an Abstract Syntax Tree (AST) from tokens.
  3. Binder: Resolves identifiers to their declarations, builds symbol tables.
  4. Checker: Performs type checking, control flow analysis, and error reporting.
  5. Emitter: Generates JavaScript and type declarations (.d.ts).
  6. Language Service: Provides editor features (autocomplete, go-to-definition, refactoring).

The Checker is the heart of static analysis. It's what proves your types are correct.


The Language Service

The language service is a persistent process that runs in your editor (VS Code, IntelliJ, etc.). It provides:

  • Autocomplete: As you type, it suggests completions based on the type context.
  • Type hints: Hover over a variable to see its type.
  • Go-to-definition: Jump from a usage to its declaration.
  • Find references: Find all places a symbol is used.
  • Quick fixes: Suggest fixes for common errors (e.g., "Add missing property").
  • Rename refactoring: Rename a symbol across all files safely.

All of these are powered by the same type checker that runs when you compile. The editor is constantly recompiling in the background, giving you instant feedback.

The Editor Is Your Compiler

With TypeScript, the editor is the compiler. Every red squiggle, autocomplete suggestion, and refactor command is driven by the same type analysis that runs at build time. This tight integration is what makes TypeScript so productive.


Practical Example: Refactoring a Function Signature

Let's refactor a function signature and see how the compiler guides us:

Code Block
TypeScript 5.7

Now suppose we change the signature to accept an options object instead of a bare id:

Code Block
TypeScript 5.7

The compiler tells you exactly which call sites need updating. No manual search, no chance of missing one. This scales to codebases with hundreds of thousands of lines.


Dead Code Detection

The never type helps detect unreachable code. If a branch is provably unreachable, the compiler can infer never:

Code Block
TypeScript 5.7

If you add another else after the value === 0 case, it's unreachable, and the compiler can warn you.


Challenge: Exhaustive Discriminated Union

Challenge
TypeScript 5.7
Exhaustive Payment Handling

Define a discriminated union Payment with three variants: { method: "cash" }, { method: "card"; last4: string }, { method: "crypto"; address: string }.

Write a function processPayment(payment: Payment): string that handles all three cases and returns a description. Use a default branch with a never check to ensure exhaustiveness.


Multiple Choice: Control Flow Analysis

QuestionSelect one

Given:

function getLength(s: string | null): number {
if (s === null) {
  return 0;
}
return s.length;
}

Why does s.length compile without error in the second return?

Because .length is always safe

Because the compiler narrows s to string after the null check

Because of a type cast


Summary

You've learned:

  • What the compiler proves: type safety, null safety, exhaustiveness, structural compatibility.
  • How control flow analysis narrows types based on runtime checks.
  • How exhaustiveness checking ensures you handle all discriminated union cases.
  • How refactoring is safe because the compiler finds every affected usage.
  • The compiler pipeline: Scanner → Parser → Binder → Checker → Emitter + Language Service.
  • How the language service powers your editor with autocomplete, go-to-definition, and refactoring.

Static analysis shifts bugs leftward — from runtime (where they're expensive and dangerous) to compile time (where they're cheap and safe). The TypeScript compiler doesn't just translate your code; it proves properties about it. And the editor integration means you get this feedback instantly, as you type.


Next: Next Steps — where to go from here on your TypeScript journey.

On this page