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:
-
You don't need to cast when TypeScript can infer it: Many developers reflexively write
return status as CheckConclusionthinking they need to "help" TypeScript match the return type. This is wrong. -
Not casting enables compile-time validation: By leaving off the cast, TypeScript validates that
statusat this point is actually assignable toCheckConclusion | undefined. If it's not, you get a compile error.
How TypeScript Validates This:
Through control flow analysis:
- First
ifhandles"in-progress"→ returnsundefined✓ - Second
ifhandles"failed"→ returns"failure"✓ - Remaining values in
statusmust be assignable toCheckConclusion - TypeScript validates:
"success" | "skipped"extendsCheckConclusion✓
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:
- Single source of truth: The SDK owns the type definition
- Automatic synchronization: Update the SDK, types update automatically
- Compile-time validation: SDK changes that break compatibility cause TypeScript errors
- Zero maintenance: No manual type definitions to keep current
How This Prevents Bugs:
Scenario: GitHub adds a new conclusion value "timeout"
- Octokit SDK updates, adding
"timeout"to the conclusion type - You run
npm update - Your
return statusline now errors (good!) - TypeScript:
Type '"skipped" | "success"' is not assignable to type 'GithubCheckConclusion' - You investigate: SDK now includes
"timeout", but your internal types don't map to it - 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#
| 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.
// ❌ 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
ascasts 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: neverfor 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.