TS

TypeScript Gotchas

Type assertions that lie, structural typing surprises, nullish vs falsy, and the gaps between types and runtime.

TypeScript Gotchas

TypeScript's type system is powerful but has sharp edges. These are the pitfalls that catch people in interviews and code reviews.


1. any Type Leakage

The trap:

function parseConfig(raw: any) {
  return raw.settings.theme; // no error — any disables all checks
}

const config = parseConfig(null); // compiles fine, crashes at runtime

What happens: any disables type checking entirely — and it spreads. Anything derived from any is also any. One any at the boundary can silently infect your entire codebase.

The fix:

function parseConfig(raw: unknown) {
  if (typeof raw === "object" && raw !== null && "settings" in raw) {
    // narrow the type safely
  }
}

Use unknown instead of any — it forces you to narrow before accessing properties.


2. Structural Typing Surprises

The trap:

interface User {
  name: string;
  email: string;
}

const data = { name: "Alice", email: "[email protected]", password: "secret123" };
const user: User = data; // No error! Extra properties are allowed.

sendToClient(user); // user still has .password at runtime

What happens: TypeScript uses structural typing — an object just needs the required properties. Extra properties are only rejected on inline object literals (excess property checking), not on variables.

The fix:

// Explicitly pick the properties you need
const user: User = { name: data.name, email: data.email };

// Or use a mapper/pick utility
const user = pick(data, ["name", "email"]);

3. as Assertions Don't Convert at Runtime

The trap:

interface ApiResponse {
  id: number;
  name: string;
}

const response = await fetch("/api/user");
const data = (await response.json()) as ApiResponse;
// data.id could be undefined, a string, anything — 'as' doesn't check

What happens: as is a compile-time assertion only. It tells TypeScript "trust me, this is the right shape." It does zero runtime validation. The actual data could be completely different.

The fix:

// Validate at the boundary with a runtime check or library (e.g., zod)
import { z } from "zod";

const ApiResponseSchema = z.object({
  id: z.number(),
  name: z.string(),
});

const data = ApiResponseSchema.parse(await response.json());

4. ?. vs &&, ?? vs ||

The trap:

const port = config.port || 3000;
// If config.port is 0, this returns 3000!

const name = user.name || "Anonymous";
// If user.name is "", this returns "Anonymous"!

What happens: || returns the right side for any falsy value: 0, "", false, null, undefined, NaN. The nullish coalescing operator ?? only triggers for null or undefined.

The fix:

const port = config.port ?? 3000;    // only if null/undefined
const name = user.name ?? "Anonymous"; // only if null/undefined

// Similarly: ?. vs &&
user?.address?.city   // safe navigation, returns undefined if null/undefined
user && user.address && user.address.city // treats 0, "", false as falsy too

5. Object.keys() Returns string[]

The trap:

interface Config {
  host: string;
  port: number;
}

const config: Config = { host: "localhost", port: 8080 };
Object.keys(config).forEach((key) => {
  console.log(config[key]);
  // Error: Element implicitly has an 'any' type because
  // expression of type 'string' can't be used to index type 'Config'
});

What happens: Object.keys() returns string[], not (keyof T)[]. TypeScript does this intentionally because objects can have more properties at runtime than the type declares (structural typing).

The fix:

// Option 1: Type assertion (if you're sure of the shape)
(Object.keys(config) as (keyof Config)[]).forEach((key) => {
  console.log(config[key]);
});

// Option 2: Use a type-safe entries helper
function typedEntries<T extends object>(obj: T) {
  return Object.entries(obj) as [keyof T, T[keyof T]][];
}

6. Enum Pitfalls

The trap:

enum Status {
  Active,    // 0
  Inactive,  // 1
}

// Numeric enums have reverse mapping
console.log(Status[0]); // "Active" — string, not Status!
console.log(Status[99]); // undefined — no error!

function setStatus(s: Status) { /* ... */ }
setStatus(99); // No error! Any number is accepted.

What happens: Numeric enums accept any number, not just defined members. They also create reverse mappings (Status[0] === "Active"), which is confusing.

The fix:

// Prefer union types or string enums
type Status = "active" | "inactive";

// Or string enums (no reverse mapping, stricter)
enum Status {
  Active = "active",
  Inactive = "inactive",
}

7. Type Widening

The trap:

let status = "active";
// type is string, not "active"

function setStatus(s: "active" | "inactive") { /* ... */ }
setStatus(status); // Error: string is not assignable to "active" | "inactive"

What happens: let declarations widen literal types to their base type ("active" becomes string). TypeScript assumes you might reassign the variable.

The fix:

const status = "active"; // type is "active" (literal)

// Or use 'as const' for complex values
const config = { status: "active" } as const;
// config.status is "active", not string

8. Readonly<T> is Shallow

The trap:

interface Config {
  db: { host: string; port: number };
}

const config: Readonly<Config> = {
  db: { host: "localhost", port: 5432 },
};

config.db = { host: "x", port: 0 }; // Error (good)
config.db.port = 9999;               // No error! (bad)

What happens: Readonly<T> only makes the top-level properties readonly. Nested objects are still mutable.

The fix:

// Use a recursive DeepReadonly type
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

const config: DeepReadonly<Config> = { /* ... */ };
config.db.port = 9999; // Error now!

9. Index Signatures Need noUncheckedIndexedAccess

The trap:

interface Cache {
  [key: string]: string;
}

const cache: Cache = {};
const value = cache["missing-key"];
// TypeScript says: string (not string | undefined!)
console.log(value.toUpperCase()); // compiles fine, crashes at runtime

What happens: By default, TypeScript assumes index signatures always return the declared type — never undefined. This is a lie for any key that doesn't exist.

The fix:

// tsconfig.json
{
  "compilerOptions": {
    "noUncheckedIndexedAccess": true
  }
}
const value = cache["missing-key"]; // now: string | undefined
if (value !== undefined) {
  console.log(value.toUpperCase()); // safe
}

10. Promise Error Handling

The trap:

async function fetchUser(id: string) {
  const res = await fetch(`/api/users/${id}`);
  return res.json(); // no .ok check, no try/catch
}

// Caller forgets to await or catch
fetchUser("123"); // unhandled promise rejection if it fails

What happens: async functions always return a Promise. If you don't await the call, errors vanish silently. And fetch doesn't throw on 404/500 — you need to check res.ok yourself.

The fix:

async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) {
    throw new Error(`Failed to fetch user: ${res.status}`);
  }
  return res.json();
}

// Always handle the promise
try {
  const user = await fetchUser("123");
} catch (err) {
  console.error("Failed:", err);
}

11. Date Gotchas (Months Are 0-Indexed)

The trap:

// "I want January 31, 2025"
const date = new Date(2025, 1, 31);
console.log(date); // March 3, 2025 — what?!

// "I want December"
const dec = new Date(2025, 12, 1);
console.log(dec); // January 1, 2026!

What happens: JavaScript's Date months are 0-indexed: January is 0, December is 11. So new Date(2025, 1, 31) means "February 31" — which overflows to March 3. And month 12 overflows to January of the next year. Days and years are 1-indexed as normal, making it even more confusing.

Other surprises:

  • getDay() returns day of the week (0 = Sunday), not day of the month — use getDate() for that
  • getYear() returns years since 1900 (deprecated) — use getFullYear()
  • Parsing date strings (new Date("2025-01-15")) is treated as UTC, but new Date("01/15/2025") is local time

The fix:

// Remember: month is 0-indexed
const jan31 = new Date(2025, 0, 31); // January 31, 2025

// Use named constants to make it readable
const JANUARY = 0, FEBRUARY = 1, MARCH = 2; // ... etc
const date = new Date(2025, JANUARY, 31);

// Or use ISO strings (always UTC, months are 1-indexed as expected)
const date2 = new Date("2025-01-31T00:00:00");

// getDate() for day of month, getDay() for weekday
console.log(date.getDate());     // 31
console.log(date.getDay());      // 5 (Friday)
console.log(date.getFullYear()); // 2025 (not getYear()!)