Common TypeScript Pitfalls & Best Practices

intermediate pitfalls best-practices type-safety

TypeScript is a leaky abstraction over JavaScript, and that creates some weird footguns. Here’s the collection of pitfalls that show up over and over in code reviews — knowing these separates juniors from seniors in interviews.

any vs unknown — the most important habit

Both can hold any value, but they behave opposite. any opts out of type checking entirely; unknown opts in but requires us to narrow before using the value.

function processAny(data: any) {
  data.foo.bar.baz();  // compiles fine, crashes at runtime if data is null
}

function processUnknown(data: unknown) {
  // data.foo;  // error — can't access anything until we narrow
  if (typeof data === "object" && data !== null && "foo" in data) {
    // now TS knows data has foo
  }
}

In simple language: any is “trust me bro”, unknown is “prove it first”. Use unknown for all external data — JSON, API responses, catch (e). Reserve any for genuine escape hatches (and try to never write it).

Exhaustiveness checks with never

When we switch on a union, we want TS to yell at us if we add a new variant and forget a case. The pattern is never in the default branch.

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number };

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.radius ** 2;
    case "square": return s.side ** 2;
    default:
      const _exhaustive: never = s; // error if we add a Shape variant
      return _exhaustive;
  }
}

Add { kind: "triangle"; ... } to Shape and the never assignment instantly fails. The compiler catches the missing case before we ship it.

Structural typing surprises

TS is structural — two types are compatible if they have the same shape, regardless of name. This sounds great until it bites us.

type UserId = string;
type ProductId = string;

function getUser(id: UserId) { /* ... */ }

const productId: ProductId = "abc";
getUser(productId); // compiles fine — both are just strings!

Both UserId and ProductId are aliases for string, so TS sees no difference. Mistakes like passing a product ID where a user ID is expected slide right through. Enter branded types.

Branded (nominal) types

A common workaround for structural mishaps. We “brand” a type with a unique tag that exists only in the type system.

type Brand<T, B> = T & { readonly __brand: B };

type UserId = Brand<string, "UserId">;
type ProductId = Brand<string, "ProductId">;

function asUserId(s: string): UserId { return s as UserId; }
function asProductId(s: string): ProductId { return s as ProductId; }

const u = asUserId("u_123");
const p = asProductId("p_456");

function loadUser(id: UserId) { /* ... */ }
loadUser(u);  // works
// loadUser(p); // error — ProductId not assignable to UserId

At runtime they’re plain strings — zero overhead. At compile time TS treats them as distinct. Use this for IDs, units (Meters vs Feet), validated strings (Email vs string), and other “same shape, different meaning” cases.

Pitfall → Fix
any everywhere
use unknown + narrow
switch missing cases
never check in default
ID type confusion
branded types
arr[0] is T (lies)
noUncheckedIndexedAccess
@ts-ignore everywhere
fix root cause / @ts-expect-error

Array access lies

By default, arr[i] is typed as the element type — but at runtime it can be undefined. This is one of TS’s biggest holes.

const users = ["Ada", "Linus"];
const x = users[5]; // type: string, but actual value: undefined!
x.toUpperCase();    // TS happy, runtime crash

Fix: enable noUncheckedIndexedAccess: true in tsconfig. Now users[5] is typed string | undefined and we’re forced to handle the missing case. Game-changer for safety.

Type predicates — narrowing helpers

When we write a runtime check, TS doesn’t always follow it. Type predicates teach the compiler.

function isString(v: unknown): v is string {
  return typeof v === "string";
}

function process(input: unknown) {
  if (isString(input)) {
    input.toUpperCase(); // narrowed to string
  }
}

The is string return type is the signal. Use these for custom guards (e.g., isUser, isErrorResponse). They’re way better than as casts.

@ts-ignore is a smell

// @ts-ignore silences the next line’s errors. Don’t use it. Two better options:

// @ts-expect-error — fails the build if the error goes away (cleans up itself)
const x: string = 42;

// @ts-expect-error
someMethodWithKnownBadTypes();

@ts-expect-error is self-cleaning. If we fix the underlying issue later, TS warns us that the suppression is now unnecessary. Trust me, future-us will appreciate it.

A few more quick wins

  • Don’t use Function type — too broad. Use a specific signature like (...args: any[]) => any.
  • Don’t use object type — also too broad. Use Record<string, unknown> or a specific shape.
  • Don’t use Number, String, Boolean (capital first letter) — use lowercase number, string, boolean. The capitalized ones refer to wrapper objects, almost never what we want.
  • Prefer interface for objects we’ll extend, type for unions and computed types — both work for most cases, but interface supports declaration merging.

The theme across all of these: TypeScript only helps if we let it. Every any, as, and @ts-ignore is a tiny hole in our safety net. Plug them.