---
title: "TypeScript Type Safety: From Runtime Errors to Compile-Time Guarantees"
description: "From Runtime Errors to Compile-Time Guarantees"
canonicalUrl: "https://zuplo.com/blog/2025/11/04/typescript"
pageType: "blog"
date: "2025-11-04"
authors: "nate"
image: "https://zuplo.com/og?text=TypeScript%20Type%20Safety%3A%20From%20Runtime%20Errors%20to%20Compile-Time%20Guarantees"
---
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:

```typescript
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.

```typescript
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):**

```typescript
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

```typescript
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:**

```typescript
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

```typescript
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:

```typescript
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)

```typescript
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:**

```typescript
// ❌ 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:

```typescript
// 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

| Pattern             | Compile-Time Safety | Runtime Safety | Maintainability | Resilience                            |
| ------------------- | ------------------- | -------------- | --------------- | ------------------------------------- |
| 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:**

| Pattern             | TypeScript 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:**

| Pattern             | TypeScript 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.

```typescript
// ❌ 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:**

```typescript
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**

```bash
# 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**

```bash
# 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:

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

Extract to utility functions:

```typescript
// ✅ 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.