Have 30-200 Employees? Make $50k-$500k selling your data for AI Training.

Learn More
How to Detect Breaking API Changes with TypeScript and OpenAPI
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
  • Premium Results
  • Publish articles on SitePoint
  • Daily curated jobs
  • Learning Paths
  • Discounts to dev tools
Start Free Trial

7 Day Free Trial. Cancel Anytime.

A backend team renames a field from userName to user_name as part of a routine cleanup. The frontend compiles without a single warning. Tests pass. The pull request merges. Hours later, production users see blank names everywhere because the UI reads a property that no longer exists. This tutorial walks through building a CI pipeline that catches breaking API changes at two distinct layers.

How to Detect Breaking API Changes with TypeScript and OpenAPI

  1. Commit a baseline OpenAPI spec (openapi/api.yaml) to your main branch as the single source of truth.
  2. Install oasdiff via Go and openapi-typescript plus TypeScript as dev dependencies in your project.
  3. Configure a GitHub Actions workflow triggered on pull requests that modify openapi/ or src/ paths.
  4. Extract the baseline spec from the main branch using git show in the CI pipeline.
  5. Run oasdiff breaking to compare the baseline and PR specs, failing the build on breaking changes.
  6. Generate TypeScript types from the updated spec using npx openapi-typescript.
  7. Compile the frontend with tsc --noEmit to verify all code references match the regenerated types.
  8. Review CI output to identify both the API-level break (oasdiff report) and the affected frontend code (compiler errors).

Table of Contents

The Silent Contract Break

A backend team renames a field from userName to user_name as part of a routine cleanup. The frontend compiles without a single warning. Tests pass. The pull request merges. Hours later, production users see blank names everywhere because the UI reads a property that no longer exists. This scenario represents a class of bugs that most frontend teams have no automated defense against.

This is contract drift: the growing, invisible gap between what an API actually returns and what the frontend code expects. Detecting breaking API changes with TypeScript and OpenAPI requires more than type annotations. It demands a systematic enforcement checkpoint embedded directly in continuous integration.

Detecting breaking API changes with TypeScript and OpenAPI requires more than type annotations. It demands a systematic enforcement checkpoint embedded directly in continuous integration.

This tutorial walks through building a CI pipeline that catches breaking API changes at two distinct layers. The first layer uses oasdiff, a CLI tool for diffing OpenAPI specifications, to surface structural changes before code is even touched. The second layer uses openapi-typescript to generate TypeScript types from the spec, then runs tsc to verify the frontend still compiles against the new contract. The full stack includes OpenAPI 3.0.3, oasdiff, openapi-typescript, and GitHub Actions, with a decision table comparing this approach against Zod runtime validation and tRPC.

Prerequisites

Before following along, ensure your repository contains:

  1. Install Go 1.26+ (required for go install of the current oasdiff release).
  2. Install Node.js 22.x (an active LTS line) and its bundled npm.
  3. Commit a package.json with openapi-typescript@7 and typescript as devDependencies, along with a package-lock.json (run npm install and commit the lockfile).
  4. Add a tsconfig.json to the repository root (see example below).
  5. Commit an openapi/api.yaml baseline spec to the main branch.
  6. Enable GitHub Actions on the repository.

Minimal package.json:

{
  "name": "api-contract-check",
  "private": true,
  "devDependencies": {
    "openapi-typescript": "7.13.0",
    "typescript": "5.9.3"
  }
}

Minimal tsconfig.json:

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "noEmit": true
  },
  "include": ["src"]
}

Why Compiled TypeScript Doesn't Protect You

The False Safety of any and Hand-Written Types

Hand-maintained TypeScript interfaces have no binding relationship to the actual API contract. They represent a developer's understanding of the response shape at a moment in time. When the backend changes, the interface stays frozen.

Consider a typical pattern:

// Hand-written interface — no connection to the real API
interface UserResponse {
  id: number;
  userName: string;
  email: string;
}

async function getUser(id: number): Promise<UserResponse> {
  const res = await fetch(`/api/users/${id}`);
  return res.json(); // returns whatever the server sends — cast to UserResponse
}

// Compiles perfectly. No warnings.
const user = await getUser(1);
console.log(user.userName); // undefined at runtime — backend now sends `user_name`

The fetch call's res.json() returns Promise; TypeScript accepts an unsafe cast to UserResponse without verification. When the backend renames userName to user_name, no compiler error fires. The value of user.userName silently becomes undefined, and the bug ships.

Where the Feedback Loop Breaks

The typical failure path follows a predictable sequence: the API spec changes, the backend team sends no notification to the frontend team, CI stays green because the hand-written types still match themselves, and production breaks. The missing piece is a contract enforcement checkpoint in CI, something that detects when the spec and the frontend's assumptions diverge before the code ever reaches a deployed environment.

Approach 1: Spec-Level Breaking Change Detection with oasdiff

What oasdiff Does

oasdiff is a CLI tool that compares two OpenAPI spec revisions and classifies every difference as breaking or non-breaking. It detects removed endpoints, changed required fields, response schema changes, modified status codes, added required request parameters, and changed property types. It does one thing: answer whether an API change will break existing consumers.

Setting Up oasdiff Locally

Install oasdiff via Docker or directly with Go. The examples below use the Go installation method, which the CI workflow later in this article also uses. The Docker image works for local use but does not appear in the GitHub Actions workflow shown here.

# Install via Go (pin to a specific version for reproducibility)
go install github.com/oasdiff/oasdiff@v1.33.0

# Ensure the Go bin directory is on your PATH
export PATH="$HOME/go/bin:$PATH"

# Or pull the Docker image for local use
docker pull tufin/oasdiff

# Run a breaking-change check between two spec files
oasdiff breaking api-v1.yaml api-v2.yaml

# Sample output:
# 1 breaking changes detected
# error   [response-property-removed] at /users/{id}
#         removed the required response property 'userName'

Creating a Baseline and Revised Spec for Testing

To demonstrate detection, two minimal OpenAPI specs are needed. The first represents the current contract:

# api-v1.yaml (baseline)
openapi: "3.0.3"
info:
  title: User Service
  version: "1.0.0"
paths:
  /users/{id}:
    get:
      operationId: getUser
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: A single user
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserResponse"
components:
  schemas:
    UserResponse:
      type: object
      required: [id, userName, email]
      properties:
        id:
          type: integer
        userName:
          type: string
        email:
          type: string

The revised spec renames userName to user_name:

# api-v2.yaml (revised)
openapi: "3.0.3"
info:
  title: User Service
  version: "2.0.0"
paths:
  /users/{id}:
    get:
      operationId: getUser
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: A single user
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserResponse"
components:
  schemas:
    UserResponse:
      type: object
      required: [id, user_name, email]
      properties:
        id:
          type: integer
        user_name:
          type: string
        email:
          type: string

Reading the oasdiff Output

Running oasdiff breaking api-v1.yaml api-v2.yaml produces output that identifies the severity level (error), the affected path (/users/{id}), and a description of the breaking change (the required response property userName was removed). By default oasdiff breaking only reports what it finds and exits with code 0. Add --fail-on ERR and it exits with code 1 when it finds error-level breaking changes. That exit code makes it usable as a CI gate without any wrapper scripting.

Approach 2: TypeScript Type Generation with openapi-typescript

Generating Types from the OpenAPI Spec

openapi-typescript reads an OpenAPI spec and emits TypeScript type definitions that mirror the contract exactly. Unlike hand-written interfaces, these types derive directly from the spec, so they reflect every field, every required marker, and every type constraint the API declares.

Note: The generated output format varies between major versions of openapi-typescript. The examples below assume openapi-typescript v7.x. Pin the version explicitly in package.json (e.g., "openapi-typescript": "7.13.0") to ensure consistent output across environments. Running npx openapi-typescript without a locally installed version may resolve a different major version, producing incompatible type shapes.

# Generate types from the revised spec (using the locally installed version)
npx openapi-typescript api-v2.yaml -o src/api-types.ts

The generated output uses a namespace structure with paths, operations, and components sections. The exact shape depends on your openapi-typescript version, but for v7.x the components schema types are accessible under components["schemas"]. After running the command above, inspect src/api-types.ts to confirm the generated structure. The key detail is that userName is gone. The type now only contains user_name, exactly matching the revised spec.

A simplified representation of the relevant generated types:

// src/api-types.ts (generated — do not edit)
// Actual output contains additional paths/operations/webhooks types.
// The schema types are nested under the components interface:
export interface components {
  schemas: {
    UserResponse: {
      id: number;
      user_name: string;
      email: string;
    };
  };
}

Important: Run npx openapi-typescript against your own spec and review the actual generated file. The snippet above is a simplified illustration of the schema portion. Your generated file will contain additional exported types for paths, webhooks, operations, and other OpenAPI constructs.

Wiring Generated Types into Frontend Code

When the frontend imports and uses the generated types, tsc catches mismatches at compile time. The example below also includes a runtime type guard to validate the response shape, since fetch returns untyped data and a type assertion alone provides no runtime safety:

// src/user-service.ts
import type { components } from "./api-types";

type UserResponse = components["schemas"]["UserResponse"];

function isUserResponse(value: unknown): value is UserResponse {
  if (typeof value !== "object" || value === null) return false;
  const v = value as Record<string, unknown>;
  return (
    typeof v["id"] === "number" &&
    typeof v["user_name"] === "string" &&
    typeof v["email"] === "string"
  );
}

async function getUser(id: number): Promise<UserResponse> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) {
    throw new Error(`HTTP ${res.status} fetching user ${id}`);
  }
  const body: unknown = await res.json();
  if (!isUserResponse(body)) {
    throw new Error(`Unexpected response shape for user ${id}`);
  }
  return body;
}

async function displayUser(id: number): Promise<void> {
  const user = await getUser(id);
  // This line now fails compilation:
  // console.log(user.userName);
  //               ^^^^^^^^
  // Property 'userName' does not exist on type 'UserResponse'.
  // Did you mean 'user_name'? ts(2339)

  // Correct usage:
  console.log(user.user_name);
}

The compiler surfaces the exact field that changed, pointing the developer to the correct property name. The bug is caught before the code leaves the developer's machine. The runtime type guard additionally ensures that malformed or unexpected responses throw an explicit error rather than silently propagating undefined values.

Note: The primary error code for a nonexistent property is ts(2339). TypeScript may additionally show ts(2551) ("Did you mean...?") when it detects a close match. Both indicate the same underlying mismatch.

Why Type Generation Alone Isn't Enough

Generated types catch drift only if a developer regenerates them and runs tsc. This is an on-demand mechanism. Without spec diffing, reviewers evaluating the upstream change never see the nature of the break: whether it is a removed field, a type change, or a new required parameter. Spec diffing provides the diagnostic; type generation provides the verification. Both layers serve different audiences and failure modes.

Combining Both Layers in a GitHub Actions Pipeline

Pipeline Architecture Overview

The CI flow operates in two stages. Stage one runs oasdiff to compare the incoming spec against the baseline from the main branch. If oasdiff detects breaking changes, the build fails immediately, giving API reviewers a clear signal before any frontend code is evaluated. Stage two depends on stage one passing (or being explicitly overridden). It runs openapi-typescript to regenerate types and then executes tsc --noEmit to validate that the frontend compiles against the new contract.

Both stages matter for distinct reasons. Spec diffing catches API-side regressions that may affect any consumer, not just the TypeScript frontend. Type checking catches frontend-side assumptions, places where the application code references fields or structures that have shifted under the new contract.

Spec diffing catches API-side regressions that may affect any consumer, not just the TypeScript frontend. Type checking catches frontend-side assumptions, places where the application code references fields or structures that have shifted under the new contract.

The Full GitHub Actions Workflow

Prerequisite: Your repository must contain a committed package.json (with openapi-typescript and typescript in devDependencies), a package-lock.json, and a tsconfig.json. See the Prerequisites section at the top of this article for examples.

# .github/workflows/api-contract-check.yml
name: API Contract Check

on:
  pull_request:
    paths:
      - "openapi/**"
      - "src/**"

jobs:
  spec-diff:
    name: Spec Breaking Change Detection
    runs-on: ubuntu-latest
    steps:
      - name: Checkout PR branch
        uses: actions/checkout@v7
        with:
          fetch-depth: 0  # Full clone required for git show against main

      - name: Checkout baseline spec from main
        run: |
          git fetch origin main
          if ! git show origin/main:openapi/api.yaml > "$RUNNER_TEMP/api-baseline.yaml" 2>/dev/null; then
            echo "::error::openapi/api.yaml not found on origin/main. Commit a baseline spec first."
            exit 1
          fi
          if [ ! -s "$RUNNER_TEMP/api-baseline.yaml" ]; then
            echo "::error::Baseline spec is empty."
            exit 1
          fi

      - name: Setup Go
        uses: actions/setup-go@v7
        with:
          go-version: "1.26"

      - name: Install oasdiff
        run: |
          go install github.com/oasdiff/oasdiff@v1.33.0
          echo "$HOME/go/bin" >> "$GITHUB_PATH"

      - name: Run breaking change detection
        # Exits non-zero on ERR-level breaking changes (--fail-on ERR)
        run: oasdiff breaking --fail-on ERR "$RUNNER_TEMP/api-baseline.yaml" openapi/api.yaml

  type-check:
    name: TypeScript Type Verification
    runs-on: ubuntu-latest
    needs: spec-diff
    steps:
      - name: Checkout PR branch
        uses: actions/checkout@v7

      - name: Setup Node.js
        uses: actions/setup-node@v7
        with:
          node-version: "22.x"

      - name: Install dependencies
        run: npm ci

      - name: Generate types from OpenAPI spec
        run: |
          npx openapi-typescript openapi/api.yaml -o src/api-types.ts
          if [ ! -s src/api-types.ts ]; then
            echo "::error::Type generation produced no output. Check openapi/api.yaml for errors."
            exit 1
          fi

      - name: Compile TypeScript (no emit)
        # Validates frontend code against regenerated types
        run: npx tsc --project tsconfig.json --noEmit

This workflow triggers on pull requests modifying files in openapi/ or src/. The spec-diff job fetches the baseline spec from the main branch using a full clone (fetch-depth: 0) and compares it against the PR version. The type-check job only runs if spec-diff passes, regenerates types, and validates compilation.

Common Pitfalls

The default actions/checkout uses fetch-depth: 1. Without fetch-depth: 0, the git show origin/main:openapi/api.yaml command may fail if the file was last modified more than one commit ago.

npm ci requires both package.json and package-lock.json. If only package.json exists, run npm install locally first and commit the generated lockfile.

Running tsc --noEmit without a tsconfig.json produces TS18003: No inputs were found. Commit the config shown in the Prerequisites section.

Pin both oasdiff and openapi-typescript to specific versions. Floating versions cause non-deterministic CI behavior and supply-chain risk.

The git show origin/main:openapi/api.yaml command assumes the spec lives at exactly that path on the main branch. Adjust to match your repository layout. Similarly, go install places binaries in $HOME/go/bin, so the workflow must add this directory to $GITHUB_PATH to ensure oasdiff is found in subsequent steps.

Setting moduleResolution: "bundler" in tsconfig.json requires an explicit module field set to "ES2022" or similar. Without it, TypeScript emits TS5110.

Handling Intentional Breaking Changes

Not every breaking change is accidental. For planned migrations, oasdiff supports a --fail-on flag to control which severity level triggers a non-zero exit. Without --fail-on, oasdiff breaking exits 0 even when it reports breaking changes. --fail-on ERR fails the build on error-level changes only. --fail-on WARN lowers the threshold so that warning-level changes fail it too. For example:

# Exits 1 only on ERR-level breaking changes; oasdiff reports warnings but does not fail the build
oasdiff breaking api-v1.yaml api-v2.yaml --fail-on ERR

A practical pattern is to define a PR label convention, such as breaking-api-approved, that the workflow checks before deciding whether to enforce the gate. The openapi-changes tool complements this approach by generating human-readable HTML changelogs from spec diffs for stakeholder review.

Contract Enforcement Decision Table: oasdiff vs. Zod vs. tRPC

Comparison Criteria

The following table compares three approaches across dimensions that affect real-world adoption decisions:

Criterionoasdiff + openapi-typescriptZod Runtime ValidationtRPC
Detection layerCI (spec diff + compile)Runtime (per-request)Compile-time (shared types)
Catches breaking changes before deployYesNo (runtime only)Yes (monorepo only; not across independently deployed services)
Works across language boundariesYes (any OpenAPI producer)TypeScript onlyTypeScript only, shared repo
Requires monorepo / shared codebaseNoNoYes
Runtime performance costNoneSchema parsing per callNone (RPC overhead)
Provides granular diff reportYes (oasdiff output)NoNo
Spec is the single source of truthYesNo (code is truth)No (router is truth)

When to Add Zod Alongside oasdiff

Zod and spec-level diffing are not mutually exclusive. Zod runtime validation is particularly valuable for external or third-party APIs where the consuming team does not control the spec and cannot trust that it accurately reflects actual responses. For internal services where the spec is versioned in Git and maintained alongside the implementation, oasdiff plus openapi-typescript catches drift before deployment rather than at request time.

When tRPC Is the Better Fit

In full-stack TypeScript monorepos where frontend and backend share a single codebase, tRPC eliminates the need for OpenAPI entirely. The router definition serves as both the contract and the type source, and compile-time safety is inherent within that shared build.

The trade-off is coupling: tRPC binds transport and types together, while OpenAPI keeps them independent. A spec-based approach allows non-TypeScript consumers, mobile clients, partner integrations, and documentation generators to use the same contract without modification. tRPC's compile-time guarantees also do not extend across independently deployed services. If the frontend and backend deploy separately, tRPC cannot catch breaking changes between deployment boundaries.

Extending the Pipeline: Optional Enhancements

Automated PR Comments with oasdiff Markdown Output

oasdiff can render its results in formats other than plain text, including markdown for some commands. Run oasdiff breaking --help to see which formats your installed version supports. Piping that output into a PR comment via actions/github-script gives reviewers immediate visibility into what changed without leaving the pull request interface. This is especially useful for teams where API reviewers and frontend developers review different parts of the same PR.

Spectral Linting for Spec Quality

Adding Spectral as a pre-diff lint step enforces naming conventions at authoring time. For example, Spectral's built-in oas3-schema rule validates structural correctness, and a custom rule targeting property naming (for example, one that enforces camelCase) would flag user_name as a linting error when the revised spec is committed. This prevents the inconsistency at its source rather than detecting it after the fact.

Schema Registry and Versioned Spec Storage

Storing specs in a dedicated openapi/ directory with semantic versioning provides an audit trail. When a breaking change is intentionally introduced, the version bump serves as documentation, and historical specs remain available for diffing against any prior version.

Shift Contract Verification Left

Hand-written types create an illusion of safety. They satisfy the compiler while leaving the actual API contract unverified. Spec-level diffing combined with generated types creates a real enforcement loop: the pipeline detects, classifies, and validates changes before code reaches production.

Hand-written types create an illusion of safety. They satisfy the compiler while leaving the actual API contract unverified.

The two-layer CI approach described here serves complementary purposes. oasdiff catches what broke at the API level, providing a diagnostic report for API reviewers. openapi-typescript plus tsc catches how that break affects the frontend, giving application developers a precise compiler error pointing to the affected code.

The GitHub Actions workflow above is ready to copy, adapt to any spec location, and run. The tools referenced here are oasdiff, openapi-typescript, and the broader pattern of contract-first development that treats the OpenAPI spec as the single source of truth between services. Run the workflow on your next PR and verify the output.

SitePoint TeamSitePoint Team

Sharing our passion for building incredible internet things.

© 2000 – 2026 SitePoint Pty. Ltd.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.