Skip to content
Production Ready — Part 12 of 30

TypeScript Safety: Catching Bugs Before They Ship

Written by claude-sonnet-4 · Edited by claude-sonnet-4
typescripttype-safetyjavascriptproductionstatic-analysisdebuggingruntime-errorsstrict-modezodeslint

Production Ready — Part 12 of 30


The $50,000 Typo

It was a Tuesday afternoon deploy. The engineer had done everything right by the team's informal standards: the code reviewed locally, the unit tests passed, and the CI pipeline turned green. The change was small — a status field on a deployment object. They renamed "failed" to "failure" in the UI layer to match what GitHub's API expected.

Except they didn't rename it everywhere. One spot in the integration code still sent "failed" to the GitHub Checks API. TypeScript said nothing. The type was as CheckConclusion — a type assertion that told the compiler "trust me, I know what this is." The compiler complied. The runtime did not.

The API returned a 422. Check runs stopped updating. The dashboard went dark. On-call engineers spent three hours tracing the failure through logs before finding the single-character difference between "failed" and "failure". The post-mortem estimated $50,000 in delayed releases and engineering time. The fix was four characters.

This is the story that TypeScript is supposed to prevent. And it can — if you use it correctly.


TypeScript Won. Now Use It Right.

The numbers are striking. According to the 2025 GitHub Octoverse report, TypeScript overtook both Python and JavaScript in August 2025 to become the most used language on GitHub — growing by over one million contributors in a single year, a 66% jump year-over-year. The State of JavaScript 2025 survey confirms the cultural shift: 40% of respondents now write exclusively in TypeScript, up from 34% in 2024 and 28% in 2022. Only 6% use plain JavaScript exclusively.

TypeScript has won. The problem is that a huge portion of the people writing TypeScript are writing JavaScript with type annotations bolted on — and then wondering why they still have runtime errors.

Here's the uncomfortable truth: TypeScript only catches bugs when you let it. Most of the production crashes I see in TypeScript codebases aren't despite TypeScript — they're because developers used TypeScript's escape hatches to avoid the discipline TypeScript requires.

Let's fix that.


The Three Escape Hatches That Will Burn You

1. The as Keyword (Type Assertions)

Type assertions are TypeScript's "I know better than the compiler" button. And sometimes you do. But most of the time, you're using it to make a red squiggle go away.

Here's the bug from the story above, simplified:

type DeploymentStatus = "success" | "failed" | "in-progress" | "skipped";
type CheckConclusion = "success" | "failure" | "cancelled" | "neutral" | "skipped" | "timed_out";

// 🚨 This compiles perfectly. It crashes at runtime.
function getConclusion(status: DeploymentStatus): CheckConclusion | undefined {
  return status === "in-progress" ? undefined : (status as CheckConclusion);
}

// When status === "failed", this sends "failed" to an API that expects "failure"
// TypeScript is silent. The API is not.

The as keyword tells TypeScript to skip validation. You're not fixing a type error — you're hiding it. The bug ships. The API rejects it at 2am.

The fix is an explicit conversion function:

function toCheckConclusion(status: DeploymentStatus): CheckConclusion | undefined {
  if (status === "in-progress") return undefined;
  if (status === "failed") return "failure";    // explicit mapping
  return status;  // TypeScript validates "success" | "skipped" are valid CheckConclusion values
}

Now if you ever add "pending" to DeploymentStatus, TypeScript will refuse to compile until you handle it. The bug surfaces at tsc time, not at 2am.

2. The any Type

any is even more dangerous than as. It turns off TypeScript's type checking completely — not just for one assertion, but for the entire chain of operations on that value.

// 🚨 TypeScript has given up. Every operation on 'data' is unchecked.
function processUser(data: any) {
  return data.user.name.toUpperCase(); // TypeScript: looks fine!
  // Runtime: Cannot read properties of undefined (reading 'name')
}

// ✅ Force yourself to validate before trusting
function processUser(data: unknown) {
  if (!isUser(data)) throw new Error("Invalid user shape");
  return data.user.name.toUpperCase(); // Now TypeScript knows this is safe
}

function isUser(val: unknown): val is { user: { name: string } } {
  return (
    typeof val === "object" &&
    val !== null &&
    "user" in val &&
    typeof (val as any).user?.name === "string"
  );
}

The difference between any and unknown: any lets you do anything without proof. unknown requires you to prove what the value is before using it. That proof is where bugs get caught.

3. Skipping Strict Mode

This one is insidious because it's invisible. If your tsconfig.json doesn't have "strict": true, you're running TypeScript with most of its safety nets off.

// tsconfig.json — The difference between full protection and false confidence

// ❌ Default-ish config — catches almost nothing
{
  "compilerOptions": {
    "target": "es2022"
  }
}

// ✅ Production-grade config
{
  "compilerOptions": {
    "target": "es2022",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true
  }
}

"strict": true enables a bundle of critical checks: strictNullChecks (null and undefined are their own types, not secretly compatible with everything else), noImplicitAny (you can't accidentally get an any by omission), and strictFunctionTypes (catches subtle bugs in callbacks).

According to LogRocket's TypeScript at Scale guide, at scale, the right default is strict: true from day one, with CI blocking on new type errors even if legacy debt exists.


The Right Patterns

Extract Types from Your Dependencies

The single best TypeScript practice I've seen adopted in the last two years: stop manually defining types for external APIs. Pull them from the SDK itself.

// ❌ Manually duplicated type — will drift from reality
type GitHubConclusion = "success" | "failure" | "cancelled";  // Missing values

// ✅ Extracted directly from the SDK — automatically correct
import type { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods";

type GitHubCheckConclusion = NonNullable<
  RestEndpointMethodTypes["checks"]["update"]["parameters"]["conclusion"]
>;

Same pattern works for Stripe, AWS, any SDK that ships types:

// Stripe payment status — directly from the SDK
type PaymentStatus = Stripe.PaymentIntent.Status;

// AWS S3 object type — no guessing
type S3Object = AWS.S3.Object;

When the SDK updates and adds a new status value, TypeScript will fail at compile time — not when a user triggers the new code path at midnight.

Exhaustiveness Checking with never

When you have a union type and need to handle every case, use the never trick to make the compiler enforce completeness:

type OrderStatus = "pending" | "processing" | "shipped" | "delivered" | "cancelled";

function getStatusMessage(status: OrderStatus): string {
  switch (status) {
    case "pending":     return "Order received";
    case "processing":  return "Being prepared";
    case "shipped":     return "On the way";
    case "delivered":   return "Delivered!";
    case "cancelled":   return "Order cancelled";
    default:
      // If you add a new OrderStatus and forget to handle it here,
      // TypeScript refuses to compile. The bug is caught before it ships.
      const _exhaustive: never = status;
      throw new Error(`Unhandled order status: ${status}`);
  }
}

Add "refunded" to OrderStatus without updating this switch? Compile error. Every time. Without exception.

Validate at Your Boundaries

TypeScript disappears at runtime. That's not a flaw — it's by design. Your compiled JavaScript has no type information. This means TypeScript's guarantees only hold for code you wrote and control. The moment data enters from outside — API responses, user input, environment variables, local storage — you need runtime validation.

Zod has become the standard solution:

import { z } from "zod";

// Define the schema once — get TypeScript types for free
const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  role: z.enum(["admin", "member", "viewer"]),
  createdAt: z.string().datetime(),
});

type User = z.infer<typeof UserSchema>;  // Type is derived from schema

// Use at every external boundary
async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  const data = await res.json();
  
  // This throws with a clear error if the API returns unexpected data
  // Instead of a cryptic runtime crash later
  return UserSchema.parse(data);
}

As LogRocket's 2026 TypeScript at Scale article puts it: use TypeScript for internal guarantees, runtime validation for external boundaries. One without the other is incomplete.


Making TypeScript Safety Stick on a Team

Individual discipline isn't enough. Here's how to make type safety automatic:

In your CI pipeline, add a type-check step that blocks merges:

# .github/workflows/ci.yml
- name: Type check
  run: npx tsc --noEmit

--noEmit runs the compiler for checking only without producing output files. If there are type errors, the CI job fails. Nobody merges broken types.

In your eslint config, add rules that enforce what TypeScript can't:

npm install --save-dev @typescript-eslint/eslint-plugin @typescript-eslint/parser
// .eslintrc.json
{
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint"],
  "rules": {
    "@typescript-eslint/no-explicit-any": "error",
    "@typescript-eslint/no-unsafe-assertion": "error",
    "@typescript-eslint/consistent-type-imports": "warn"
  }
}

The no-explicit-any rule catches any at lint time. no-unsafe-assertion flags as casts that TypeScript itself would allow. This turns team discipline into automated enforcement.

Track your any count over time. Search for any and : as patterns in your codebase and watch the number go down across sprints. What gets measured gets managed.


Your TypeScript Safety Checklist

Configuration

  • "strict": true in tsconfig.json
  • "noUncheckedIndexedAccess": true (catches array access bugs)
  • "exactOptionalPropertyTypes": true (prevents undefined in required fields)
  • "noImplicitReturns": true (catches functions that don't always return)

Code Patterns

  • Zero use of any — use unknown and validate instead
  • Type assertions (as) require a comment explaining why they're safe
  • External API types extracted from SDKs, not manually duplicated
  • All union type switches use default: never exhaustiveness checking
  • Runtime validation (Zod or equivalent) at every external data boundary

CI/CD

  • tsc --noEmit runs in CI and blocks merges on type errors
  • @typescript-eslint/no-explicit-any set to "error" in ESLint
  • @typescript-eslint/no-unsafe-assertion set to "error" in ESLint

Ongoing

  • Count your any and as usages monthly — the number should trend down
  • When SDK dependencies update, run tsc and treat new errors as a gift
  • Enforce strict: true on all new projects from day one

Ask The Guild

This week's community prompt: Have you ever shipped a TypeScript bug that your type annotations should have caught — but didn't because of an as cast, a sneaky any, or strict mode being off? What was the bug, and how long before you found it? Drop your story in the thread. The more embarrassing the better. Your war story is the lesson that saves someone else's Friday night.


Sources: GitHub Octoverse 2025 — TypeScript overtakes Python and JavaScript (GitHub Blog) | State of JavaScript 2025 Survey Results (InfoQ) | TypeScript Type Safety: From Runtime Errors to Compile-Time Guarantees (Zuplo) | TypeScript at Scale in 2026 (LogRocket)

Copy A Prompt Next

Review and debug

If this article changed how you think about the problem, copy a prompt that turns that judgment into one safe, reviewable next step.

Matching public prompts

23

Keep the task scoped, copy the prompt, then inspect one reviewable diff before the agent continues.

Need the safest first move instead? Open the curated sample prompts before you browse the broader library.

DebuggingWorking With AI Tools

Debug This Without Thrashing

Use this when the app is already broken and you need the agent to isolate one likely cause, propose a narrow fix, and define how to verify it.

Preview
"Help me debug this issue systematically.
Feature: [what is broken]
Error or symptom: [full message or precise symptom]
Expected behavior: [what should happen]
Actual behavior: [what happens instead]
Production Ready

Use this production insight inside a full build sequence

Production articles show you what breaks in the real world. The right path turns that lesson into a sequence you can ship with instead of just nodding at.

Best Next Path

Building a Real Product

Guild Member · $29/mo

Bridge demos to software people can trust: auth, billing, email, analytics, and the surrounding product plumbing.

20 lessonsIncluded with the full Guild Member library

Need the free route first?

Start with Start Here — Build Safely With AI if you want the workflow and vocabulary before you dive into the deeper path above.

T

About Tom Hundley

Tom Hundley writes for builders who need stronger technical judgment around AI-assisted software work. The Guild turns production experience into public articles, copy-paste prompts, and structured learning paths that help non-software developers supervise AI agents more safely.

Do this next

Leave this article with one concrete move. Copy the matching prompt, or start with the path that teaches the safest next skill in sequence.