Ivaan Information Technology
TypeScript: Avoiding Common Pitfalls
← Back to Blog
Tech Insights

TypeScript: Avoiding Common Pitfalls

✍By Khushal Chugh
πŸ—“Apr 15, 2026
⏱5 min read

TypeScript catches a large class of bugs before your code runs, but only if you let it. Many codebases adopt the syntax and keep the old habits, which leaves type holes that fail at runtime. These pitfalls come up in almost every code review we do.

1. Turning off strict mode

A new project should start with "strict": true in tsconfig.json. It enables strictNullChecks, noImplicitAny and several related checks. Without strictNullChecks, any value can be null without the compiler saying so. If you inherit a loose project, turn flags on one at a time and fix the errors in batches. Also enable noUncheckedIndexedAccess, which types array[0] as possibly undefined, the way it behaves at runtime.

2. Using any

The type any switches off checking for a value and everything it touches. Use unknown when you do not know the type, which forces you to narrow it before use.

function parse(input: unknown) {
  if (typeof input === "string") return input.trim();
  if (typeof input === "number") return String(input);
  throw new Error("Unsupported input");
}

Add the ESLint rule @typescript-eslint/no-explicit-any to catch new uses.

3. Casting instead of validating

The expression data as User tells the compiler to trust you. If an API returns a different shape, the cast hides it until the code crashes. Validate external data at the boundary with a runtime schema and derive the type from it:

import { z } from "zod";

const User = z.object({ id: z.number(), email: z.string().email() });
type User = z.infer<typeof User>;

const user = User.parse(await res.json());

4. The non-null assertion

The postfix ! in user!.name silences a null check. When the assumption fails, you get the same crash TypeScript would have prevented. Check for the value, return early, or use optional chaining and a default instead.

5. Wide types for known values

A function that takes status: string accepts typos. Use a union of literals for a fixed set of values:

type Status = "pending" | "paid" | "failed";
function label(status: Status) { /* ... */ }

Use the as const assertion on object constants, and the satisfies operator to check an object against a type while keeping its precise inferred type.

6. Optional fields that model states badly

An interface with data?: Data and error?: Error allows impossible combinations, such as both set or both missing. A discriminated union describes each real state:

type Result =
  | { status: "loading" }
  | { status: "success"; data: Data }
  | { status: "error"; error: Error };

The compiler then forces you to handle each case, and a switch with an exhaustive never check flags states you forgot when someone adds a new one.

7. Trusting object keys

Object.keys() returns string[], not the key union, because objects can have extra properties at runtime. Prefer Object.entries with a typed record, or a Map, when you need type-safe iteration. For dictionary lookups, a Record<string, T> access returns T unless noUncheckedIndexedAccess is on.

8. Ignoring errors in catch blocks

With useUnknownInCatchVariables (part of strict mode), the caught value is unknown. Narrow it with error instanceof Error before you read .message.

9. Overusing enums and complex generics

Numeric enums have surprising runtime behavior. String literal unions or as const objects are simpler and tree-shake better. nested conditional types slow the compiler and puzzle teammates. If a type needs a paragraph of explanation, simplify the design.

10. Not type-checking in CI

Bundlers like Vite and Next.js strip types without checking them. Run tsc --noEmit in your pipeline so type errors fail the build.

A short adoption plan

  1. Turn on strict and fix errors by folder.
  2. Ban any with a lint rule and allow exceptions with a comment.
  3. Validate all external input with schemas.
  4. Model states with discriminated unions.
  5. Add tsc --noEmit to CI.

Common questions

Is it worth migrating a large JavaScript codebase?

Usually yes, if you do it gradually. Set allowJs and checkJs in your configuration, rename files to .ts and .tsx one folder at a time, and start with the modules that change most often. Teams typically see fewer runtime errors and faster onboarding within a few months, though the first weeks feel slower.

Should I use interface or type?

Both describe object shapes. Use interface for public object contracts that others may extend, and type for unions, intersections and mapped types. Pick one convention for plain object shapes and apply it everywhere so the codebase reads consistently.

How do I type third-party libraries without types?

Check DefinitelyTyped first with npm i -D @types/library-name. If none exists, write a small declaration file with the functions you use and type the rest as unknown. Contribute the declaration upstream when it is solid.

Do types slow down builds?

Type checking adds time on large projects. Use incremental builds with "incremental": true, project references for monorepos, and run tsc --noEmit in CI instead of during every dev server refresh. Modern bundlers strip types without checking, so the dev loop stays fast.

We migrate JavaScript codebases to strict TypeScript and review existing ones. Reach out for a code audit, or see our web development services.

Explore Our Services

Need help putting this into practice? See our web, mobile and AI development services, browse projects we have delivered, read the FAQs, or contact our team.