WritingTypeScript Mistakes That Still Cause Runtime Errors in Production — Clixo
5 min readtypescript, runtime-errors, type-safety, common-mistakes, production

TypeScript Mistakes That Still Cause Runtime Errors in Production

TypeScript catches many bugs at compile time — but these common mistakes let real errors slip through to production. Learn what to avoid and why.

TypeScript's value proposition is catching errors before your users do. But TypeScript is a compile-time tool — it erases all type annotations before execution. A false sense of type safety is sometimes worse than no type safety at all, because developers stop writing defensive code.

Here are the TypeScript mistakes that engineers make repeatedly, all of which allow runtime errors to reach production despite a green tsc output.

Common TypeScript Mistakes That Slip Past the Compiler

Casting with as Instead of Validating

The most dangerous escape hatch in TypeScript is the type assertion:

const user = JSON.parse(response.body) as User;
console.log(user.email.toLowerCase()); // crash if email is missing

as User does not validate anything. It is a compile-time instruction that tells TypeScript "trust me, this is a User." At runtime, the JSON might be malformed, missing fields, or from an API that changed its schema. TypeScript will not protect you.

The fix is runtime validation at every trust boundary — any data that comes from outside your process:

import { z } from "zod";
 
const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
});
 
const user = UserSchema.parse(JSON.parse(response.body));
// Now user is actually validated — not just asserted

Zod, Valibot, and Arktype all serve this purpose. Pick one and use it consistently at API boundaries, database read paths, and environment variable access.

Relying on Default Array Index Types

Without noUncheckedIndexedAccess in your tsconfig, TypeScript lies to you about arrays:

const items: string[] = [];
const first = items[0];
first.toUpperCase(); // TypeScript says string — runtime crash: Cannot read properties of undefined

Even with strict: true, this compiles cleanly. The flag noUncheckedIndexedAccess is separate from the strict preset. Without it, arr[n] has type T, not T | undefined. Enable it and fix the downstream errors — they are all real bugs.

Non-Null Assertions on Values That Can Be Null

The non-null assertion operator ! is the second most dangerous escape hatch:

const el = document.getElementById("app")!;
el.innerHTML = "<p>hello</p>"; // crash if element does not exist

The ! tells TypeScript the value is definitely not null or undefined. If you are wrong, you get a runtime error with no TypeScript warning. Use explicit guards instead:

const el = document.getElementById("app");
if (!el) throw new Error("Missing #app element");
el.innerHTML = "<p>hello</p>";

The guard is one extra line and makes the failure mode explicit and intentional.

Ignoring the unknown Type in Catch Blocks

With useUnknownInCatchVariables (included in strict: true since TypeScript 4.4), catch variables have type unknown. But many developers immediately cast them:

try {
  await fetchData();
} catch (err) {
  console.error((err as Error).message); // crashes if err is a string or network object
}

Not every thrown value is an Error. External libraries, rejected promises, and legacy code throw strings, plain objects, or custom error types. Check before you cast:

try {
  await fetchData();
} catch (err) {
  const message = err instanceof Error ? err.message : String(err);
  console.error(message);
}

Typing Environment Variables as string

process.env.DATABASE_URL has type string | undefined in TypeScript. A common mistake is to assert it is always defined:

const db = new Database(process.env.DATABASE_URL!);

If DATABASE_URL is missing from a deployment environment, the application will fail at runtime — often with a cryptic error from the database client rather than a clear "missing environment variable" message.

Validate environment variables at startup and fail fast:

function requireEnv(key: string): string {
  const value = process.env[key];
  if (!value) throw new Error(`Missing required environment variable: ${key}`);
  return value;
}
 
const config = {
  databaseUrl: requireEnv("DATABASE_URL"),
  apiKey: requireEnv("API_KEY"),
};

Your application either starts correctly or it tells you exactly what is missing. No mystery crashes later.

Structural Typing Surprises with External Data

TypeScript uses structural typing: if an object has the right shape, TypeScript accepts it. But this means you can accidentally satisfy an interface with an object that has extra (or semantically wrong) fields:

interface OrderId {
  value: string;
}
 
interface UserId {
  value: string;
}
 
function getOrder(id: OrderId) { /* ... */ }
 
const userId: UserId = { value: "user-123" };
getOrder(userId); // TypeScript accepts this — they have the same shape

For domain identifiers where mixing types is a real bug, use branded types:

type OrderId = string & { readonly __brand: "OrderId" };
type UserId = string & { readonly __brand: "UserId" };
 
function makeOrderId(id: string): OrderId {
  return id as OrderId;
}

Now UserId and OrderId are structurally incompatible at the type level, even though both are strings at runtime.


TypeScript is a tool, not a guarantee. The teams that ship reliable software pair it with runtime validation, disciplined config, and code review that checks for these patterns specifically. If your team needs help designing a robust TypeScript architecture, Clixo builds production systems where type safety is treated as infrastructure, not an afterthought.