Zuplo logo
Back to all articles
November 4, 2025
27 min read

TypeScript Type Safety: From Runtime Errors to Compile-Time Guarantees

Nate Totten
Nate TottenCo-founder & CTO

A production bug revealed a fundamental misunderstanding about TypeScript's type system. The GitHub API returned a 422 error:

Invalid request.
failed is not a member of ["success", "failure", "neutral", "cancelled",
"timed_out", "action_required", "skipped"]

The root cause? A type assertion (as) that bypassed TypeScript's type checking. This post examines four approaches to the same problem, from worst to best, explaining why each pattern matters for building type-safe applications.

The Problem Domain#

Consider converting between an internal status type and an external API's type:

type DeploymentStatus = "success" | "failed" | "in-progress" | "skipped";

// GitHub API expects different string values:
// "success" → "success" (same)
// "failed" → "failure" (different!)
// "in-progress" → undefined (not a valid conclusion)
// "skipped" → "skipped" (same)

How you handle this conversion determines whether TypeScript can prevent bugs.

Pattern 1: Type Assertions (The Bug Pattern)#

The initial code was used in a way that only allowed two values for status - success and in-progress. This status variable was set with a simple ternary operation to convert the internal status to the GitHub API's expected conclusion type. The failure status was handled on a different code path.

type CheckConclusion =
  | "success"
  | "failure"
  | "action_required"
  | "cancelled"
  | "neutral"
  | "skipped"
  | "stale"
  | "timed_out";

let status: "success" | "in-progress";

// ... more code that sets `status`

const conclusion: CheckConclusion | undefined =
  status === "in-progress" ? undefined : (status as CheckConclusion);

await client.rest.checks.update({
  conclusion,
  // ...
});

Why This Pattern Fails:

The as CheckConclusion assertion tells TypeScript "trust me, this value is valid." TypeScript stops checking. When output.status is "failed", it passes through unchanged because the assertion prevents validation.

What TypeScript Should Catch (But Doesn't):

type DeploymentStatus = "success" | "failed" | "in-progress" | "skipped";
type CheckConclusion = "success" | "failure" | /* ... */;

// "failed" is NOT in CheckConclusion's union!
// But the cast silences TypeScript's error
const value: CheckConclusion = "failed" as CheckConclusion; // No error

The Critical Flaw:

Type assertions create blind spots where bugs hide. If you add a new value to DeploymentStatus, the assertion will pass it through without conversion, and you'll only discover the problem at runtime when the API rejects it.

When This Pattern Breaks:

  • Adding new enum values
  • Renaming enum values
  • Integrating with external APIs that use different conventions

This pattern caused the production bug. Never use type assertions for conversions.

Pattern 2: Explicit Conversion with Assertion Fallback#

const conclusion: CheckConclusion | undefined = isInProgress
  ? undefined
  : output.state === "failed"
    ? "failure"
    : (output.state as CheckConclusion);

Why This Is Better:

The immediate bug is fixed. The "failed""failure" conversion is explicit.

Why This Is Still Wrong:

The fallback as CheckConclusion assertion remains. For any value other than "failed" or "in-progress", TypeScript stops checking.

Example of Hidden Danger:

type DeploymentStatus =
  | "success"
  | "failed"
  | "in-progress"
  | "skipped"
  | "pending";

// New "pending" status added
// The assertion allows it through without conversion
const conclusion = toConclusion("pending"); // Returns "pending"
// API rejects "pending" at runtime

The Pattern's Weakness:

You've fixed one specific bug, but the underlying problem remains: TypeScript can't validate the conversion for other cases. This is a band-aid, not a solution.

Pattern 3: Utility Function Without Assertions#

function toGithubCheckConclusion(
  status: DeploymentStatus,
): CheckConclusion | undefined {
  if (status === "in-progress") {
    return undefined;
  }
  if (status === "failed") {
    return "failure";
  }
  // DO NOT cast here!
  // return status as CheckConclusion;  ❌ This seems helpful but removes safety
  return status; // ✅ This lets TypeScript validate it's safe
}

Why This Is Better:

This teaches two critical lessons:

  1. You don't need to cast when TypeScript can infer it: Many developers reflexively write return status as CheckConclusion thinking they need to "help" TypeScript match the return type. This is wrong.

  2. Not casting enables compile-time validation: By leaving off the cast, TypeScript validates that status at this point is actually assignable to CheckConclusion | undefined. If it's not, you get a compile error.

How TypeScript Validates This:

Through control flow analysis:

  1. First if handles "in-progress" → returns undefined
  2. Second if handles "failed" → returns "failure"
  3. Remaining values in status must be assignable to CheckConclusion
  4. TypeScript validates: "success" | "skipped" extends CheckConclusion

Testing Resilience to Change:

Add a new status:

type DeploymentStatus =
  | "success"
  | "failed"
  | "in-progress"
  | "skipped"
  | "pending";

TypeScript error:

Type '"pending"' is not assignable to type 'CheckConclusion | undefined'.

This forces you to handle the new case before deploying. The bug is caught at compile time, not production.

Why This Is Still Not Perfect:

The CheckConclusion type is manually defined. What happens when:

  • GitHub adds a new conclusion value?
  • The Octokit SDK updates?
  • You don't notice the change?

Your code compiles with outdated types. The mismatch emerges at runtime.

Pattern 4: SDK-Extracted Types (Best Pattern)#

import type { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods";

// Extract the actual type from the SDK
type GithubCheckConclusion = NonNullable<
  RestEndpointMethodTypes["checks"]["update"]["parameters"]["conclusion"]
>;

function toGithubCheckConclusion(
  status: DeploymentStatus,
): GithubCheckConclusion | undefined {
  if (status === "in-progress") {
    return undefined;
  }
  if (status === "failed") {
    return "failure";
  }
  return status;
}

Why This Is Best:

  1. Single source of truth: The SDK owns the type definition
  2. Automatic synchronization: Update the SDK, types update automatically
  3. Compile-time validation: SDK changes that break compatibility cause TypeScript errors
  4. Zero maintenance: No manual type definitions to keep current

How This Prevents Bugs:

Scenario: GitHub adds a new conclusion value "timeout"

  1. Octokit SDK updates, adding "timeout" to the conclusion type
  2. You run npm update
  3. Your return status line now errors (good!)
  4. TypeScript: Type '"skipped" | "success"' is not assignable to type 'GithubCheckConclusion'
  5. You investigate: SDK now includes "timeout", but your internal types don't map to it
  6. You make a conscious decision how to handle this before deploying

Why Manual Types Fail:

// ❌ Your manually defined type (outdated)
type CheckConclusion = "success" | "failure" | "neutral" | /* ... */;

// ✅ SDK type (current)
type ActualSDKType = "success" | "failure" | "neutral" | "timeout" | /* ... */;

// Your code compiles but uses outdated types
// Runtime: API rejects values or your code doesn't handle new values

Extracting Types from SDKs:

Most modern SDKs expose their types. Extract them programmatically:

// Octokit / GitHub API
type GithubState =
  RestEndpointMethodTypes["repos"]["createDeploymentStatus"]["parameters"]["state"];

// Stripe
import type Stripe from "stripe";
type PaymentStatus = Stripe.PaymentIntent.Status;

// AWS SDK
import type { S3 } from "@aws-sdk/client-s3";
type S3Object = S3.Object;

TypeScript's indexed access types let you drill into any exported type structure.

The Four Patterns Compared#

PatternCompile-Time SafetyRuntime SafetyMaintainabilityResilience
Type Assertion❌ None❌ Fails❌ Poor❌ Breaks silently
Assertion Fallback🟡 Partial🟡 Partial🟡 Moderate❌ Breaks silently
Utility Function✅ Full✅ Safe✅ Good✅ Errors at compile time
SDK-Extracted Types✅ Full✅ Safe✅ Excellent✅ Errors at compile time + auto-sync

Adding a New Internal Status:

PatternTypeScript Error?Runtime Error?
Type Assertion❌ No✅ Yes
Assertion Fallback❌ No✅ Yes
Utility Function✅ Yes❌ No
SDK-Extracted Types✅ Yes❌ No

SDK Adds New API Value:

PatternTypeScript Error?Discovers Issue?
Type Assertion❌ No❌ Never
Assertion Fallback❌ No❌ Never
Utility Function❌ No🟡 Maybe (if you notice)
SDK-Extracted Types✅ Yes✅ Always

Three Principles for Type-Safe Code#

Principle 1: Avoid Type Assertions#

Every as cast is a hole in your type safety. TypeScript can't help if you tell it not to check.

Legitimate uses of assertions are rare:

  • Interop with untyped JavaScript
  • Working around a TypeScript bug
  • Type guards where the relationship is complex

Rule: If you can't write a comment explaining why the assertion is safe, it probably isn't.

Principle 2: Extract Types from Dependencies#

Never manually duplicate types that exist in libraries you depend on.

// ❌ BAD: Manual duplication
type GithubState = "success" | "failure" | "error";

// ✅ GOOD: Programmatic extraction
type GithubState =
  RestEndpointMethodTypes["repos"]["createDeploymentStatus"]["parameters"]["state"];

When libraries update, extracted types update automatically. Manual types go stale silently.

Principle 3: Use Explicit Conversion Functions#

Don't scatter type conversions throughout your codebase. Extract them into utility functions.

Why:

  • TypeScript's control flow analysis works best with functions
  • Switch statements provide exhaustiveness checking
  • Conversions are testable in isolation
  • Changes to either type trigger compile errors

Pattern:

function toExternalType(internal: InternalType): ExternalType {
  switch (internal) {
    case "internal-a":
      return "external-a";
    case "internal-b":
      return "external-b";
    default:
      // Exhaustiveness check: if InternalType adds values, this errors
      const _exhaustive: never = internal;
      throw new Error(`Unhandled value: ${internal}`);
  }
}

The never type at the end provides exhaustiveness checking. If InternalType adds a value you don't handle, TypeScript errors.

Applying These Patterns#

Step 1: Find Type Assertions

# Search for type assertions
rg " as [A-Z]"

For each one, ask:

  • Why is this assertion necessary?
  • Is there a type mismatch I should fix instead?
  • Can I remove it by handling cases explicitly?

Step 2: Find Manual Type Definitions

# Find union types (potential manual API types)
rg "type \w+ =\s*$" -A 5

For each one, ask:

  • Does this type come from a dependency?
  • Can I extract it programmatically?
  • What happens when the dependency updates?

Step 3: Extract Conversion Logic

Look for inline type conversions:

// ❌ Inline conversion
state: status === "failed" ? "failure" : status;

Extract to utility functions:

// ✅ Utility function
state: toApiState(status);

Conclusion: TypeScript's Type System Is Your Safety Net#

The production bug wasn't TypeScript's failure—it was ours. We bypassed TypeScript's safety with a type assertion, creating a blind spot where bugs could hide.

The lesson: TypeScript can only catch bugs if you let it.

Type assertions tell TypeScript not to check. Manual type definitions become stale. Inline conversions prevent exhaustiveness checking.

The solution is straightforward:

  • Remove type assertions
  • Extract types from dependencies
  • Use explicit conversion functions

When you do this, TypeScript becomes a powerful ally. Add a new enum value? TypeScript errors until you handle it. Update a dependency? TypeScript errors if types changed incompatibly. Refactor code? TypeScript guides you to every place that needs updating.

This is TypeScript working as designed. Don't fight it with assertions. Work with it by writing code that lets the compiler help you.


Checklist for Type-Safe Code:

  • No as casts without comments explaining why they're necessary and safe
  • API types extracted from SDKs, not manually defined
  • Type conversions in dedicated utility functions
  • Switch statements with default: never for exhaustiveness
  • Dependency updates caught by CI (failing build = incompatible types)

When all these are true, you've moved bugs from production to compile time. That's the power of TypeScript used correctly.

Questions? Let's chat

Join our community to discuss API integration and get help from our team and other developers.

OPEN DISCORD
51members online