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
- Turn on
strictand fix errors by folder. - Ban
anywith a lint rule and allow exceptions with a comment. - Validate all external input with schemas.
- Model states with discriminated unions.
- Add
tsc --noEmitto 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.

