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.
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
Functiontype — too broad. Use a specific signature like(...args: any[]) => any. - Don’t use
objecttype — also too broad. UseRecord<string, unknown>or a specific shape. - Don’t use
Number,String,Boolean(capital first letter) — use lowercasenumber,string,boolean. The capitalized ones refer to wrapper objects, almost never what we want. - Prefer
interfacefor objects we’ll extend,typefor unions and computed types — both work for most cases, butinterfacesupports 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.