TypeScript Common Mistakes Senior Developers Still Make
TL;DR
- Replace every
anywithunknownand a type guard.anydisables type checking and propagates silently- Discriminated unions eliminate entire categories of runtime errors that type assertions mask
- Enums have surprising runtime behaviour. Prefer
as constobjects or string literal unions- Enable every
strictflag in tsconfig. Each one catches a different class of bugs
Table of Contents
- Overusing any
- Type Assertion Abuse
- Not Leveraging Discriminated Unions
- Generic Constraints
- Structural Typing Surprises
- Enums vs Const Objects vs Union Types
- Ignoring Strict Mode Flags
- Module Augmentation
- Frequently Asked Questions
Overusing any
This is the most damaging TypeScript mistake because any is contagious. One any in a function signature can propagate through your entire call chain, silently disabling type checking for every function that touches the data.
The problem:
async function fetchUser(id: string): Promise<any> {
const response = await fetch(`/api/users/${id}`);
return response.json(); // Returns any
}
// Now every consumer loses type safety
const user = await fetchUser('123');
console.log(user.nmae); // Typo! No error because user is 'any'
console.log(user.age.toFixed()); // Runtime crash if age is undefined
The fix - use unknown with type guards:
interface User {
id: string;
name: string;
email: string;
age: number;
}
function isUser(data: unknown): data is User {
return (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'name' in data &&
'email' in data
);
}
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data: unknown = await response.json();
if (!isUser(data)) {
throw new Error('Invalid user data from API');
}
return data; // TypeScript knows this is User
}
For generating accurate TypeScript interfaces from your API responses, use our JSON to TypeScript converter. Paste a real API response and get type-safe interfaces instantly.
Type Assertion Abuse
Type assertions (as SomeType) tell TypeScript "trust me, I know better." Sometimes you do. Usually you do not.
Legitimate use:
// DOM element access where TypeScript cannot infer the specific element type
const canvas = document.getElementById('myCanvas') as HTMLCanvasElement;
Abusive use:
// Hiding a type error instead of fixing it
const user = JSON.parse(jsonString) as User; // No runtime validation!
// Forcing incompatible assignments
const config = { port: "3000" } as ServerConfig; // port should be number
The fix - use validation:
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
age: z.number().int().positive(),
});
type User = z.infer<typeof UserSchema>;
function parseUser(json: string): User {
return UserSchema.parse(JSON.parse(json)); // Runtime validation + correct types
}
The rule: if you are using as to fix a type error, you are probably hiding a real bug. Use as only when you have external knowledge that TypeScript cannot infer (DOM elements, type narrowing after runtime checks).
Not Leveraging Discriminated Unions
Discriminated unions are TypeScript's most powerful pattern for modelling states. They eliminate the "impossible state" problem that causes runtime errors.
Without discriminated unions (bug-prone):
interface ApiResponse {
data?: User[];
error?: string;
isLoading: boolean;
}
function handleResponse(response: ApiResponse) {
if (response.data) {
// But what if data is [] and error is also set?
// Nothing prevents both data and error from being present
}
}
With discriminated unions (safe):
type ApiResponse =
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: string };
function handleResponse(response: ApiResponse) {
switch (response.status) {
case 'loading':
return <Spinner />;
case 'success':
return <UserList users={response.data} />;
case 'error':
return <ErrorMessage message={response.error} />;
}
// TypeScript ensures all cases are handled (exhaustive check)
}
The discriminant field (status) guarantees that only valid combinations exist. You cannot have a response with both data and error. TypeScript enforces this at compile time.
Generic Constraints
Unconstrained generics accept anything, which defeats their purpose:
Too loose:
function getProperty<T>(obj: T, key: string): any {
return (obj as any)[key]; // Unsafe: key might not exist on T
}
Properly constrained:
function getProperty<T extends object, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]; // Type-safe: K must be a valid key of T
}
const user = { name: 'Alice', age: 30 };
getProperty(user, 'name'); // Returns string
getProperty(user, 'age'); // Returns number
getProperty(user, 'email'); // Compile error: 'email' is not a key of user
The extends keyword constrains what T and K can be. Without constraints, generics are just fancy any.
Structural Typing Surprises
TypeScript uses structural typing (duck typing), not nominal typing. This means that if two types have the same shape, they are compatible, even if they have different names:
interface Cat { name: string; meow(): void; }
interface Dog { name: string; meow(): void; bark(): void; }
function greetCat(cat: Cat) {
console.log(`Hello, ${cat.name}`);
}
const dog: Dog = { name: 'Rex', meow: () => {}, bark: () => {} };
greetCat(dog); // No error! Dog has all Cat properties (and more)
This is intentional. TypeScript checks structure, not identity. But it can lead to bugs when you accidentally pass the wrong type because it happens to have the same shape.
The branded type pattern prevents this:
type UserId = string & { readonly __brand: 'UserId' };
type PostId = string & { readonly __brand: 'PostId' };
function createUserId(id: string): UserId { return id as UserId; }
function createPostId(id: string): PostId { return id as PostId; }
function getUser(id: UserId) { /* ... */ }
const userId = createUserId('user-123');
const postId = createPostId('post-456');
getUser(userId); // OK
getUser(postId); // Error! PostId is not assignable to UserId
Enums vs Const Objects vs Union Types
TypeScript enums have surprising runtime behaviour that catches developers off guard:
Numeric enums create reverse mappings:
enum Direction {
Up, // 0
Down, // 1
Left, // 2
Right, // 3
}
// At runtime, Direction contains BOTH:
// Direction[0] = "Up"
// Direction["Up"] = 0
// This doubles the object size and creates unexpected iteration behaviour
Object.keys(Direction); // ["0", "1", "2", "3", "Up", "Down", "Left", "Right"]
String enums are better but still create runtime objects:
enum Status {
Active = 'active',
Inactive = 'inactive',
}
// Compiles to: { Active: "active", Inactive: "inactive" }
// Does NOT create reverse mapping, but still creates a runtime object
The preferred alternatives:
// Option 1: String literal union (zero runtime cost)
type Status = 'active' | 'inactive' | 'pending';
// Option 2: Const object (when you need runtime access to values)
const Status = {
Active: 'active',
Inactive: 'inactive',
Pending: 'pending',
} as const;
type Status = typeof Status[keyof typeof Status];
// Status = 'active' | 'inactive' | 'pending'
Option 1 is purely a compile-time construct with zero runtime cost. Option 2 gives you both runtime values and compile-time type safety without the surprising enum behaviour.
Ignoring Strict Mode Flags
Many projects use "strict": true in tsconfig.json but do not understand what it enables. Here is what each flag does:
| Flag | What It Catches |
|---|---|
strictNullChecks | Prevents accessing properties on possibly null/undefined values |
strictFunctionTypes | Catches function parameter type variance bugs |
strictBindCallApply | Type-checks bind, call, and apply method arguments |
strictPropertyInitialization | Forces class properties to be initialised in the constructor |
noImplicitAny | Requires explicit types where TypeScript cannot infer |
noImplicitThis | Flags functions where this type is unclear |
alwaysStrict | Emits "use strict" in every file |
Enabling "strict": true activates all of these. Do not selectively disable individual flags unless you have a documented reason. Every flag catches a real bug category.
Module Augmentation
When a third-party library's types are incorrect or incomplete, the proper fix is module augmentation, not any:
// types/express.d.ts
import { User } from '@/models/user';
declare module 'express' {
interface Request {
user?: User;
requestId: string;
}
}
This extends Express's Request type globally so req.user and req.requestId are type-safe. Compare this to the common hack:
// BAD: loses all type safety
const user = (req as any).user;
When validating JSON data structures before building TypeScript types, use our free JSON formatter to inspect the exact shape and types of your data.
For generating TypeScript interfaces from JSON API responses, our JSON to TypeScript converter produces accurate interfaces that you can use as a starting point.
The Debuggers builds TypeScript applications with strict mode enabled from day one, ensuring type safety throughout the codebase.
For more on web development patterns, check our guide on React Server Components vs Client Components.
Frequently Asked Questions
When is it acceptable to use any in TypeScript?
Almost never. The only legitimate uses are: wrapping third-party JavaScript that has no type definitions (until you write module augmentation), and in test code where you intentionally pass invalid types to verify error handling. For API responses, use unknown with runtime validation. For complex type situations, use as unknown as TargetType (double assertion) with a comment explaining why.
Should I use interface or type in TypeScript?
Use interface for object shapes that might be extended or implemented by classes. Use type for unions, intersections, utility types, and any type that is not purely an object shape. In practice, the difference is minimal for most code. The key consideration is that interfaces support declaration merging (useful for module augmentation), while types support union and intersection syntax.
How do I migrate a JavaScript project to TypeScript gradually?
Start by renaming files from .js to .ts one at a time, beginning with utility functions and model files. Set "allowJs": true and "strict": false in tsconfig.json initially. Fix type errors in each converted file before moving to the next. Once all files are converted, enable strict mode flags one at a time: start with noImplicitAny, then strictNullChecks, then the rest. This incremental approach avoids the overwhelming "fix 2000 errors at once" problem.
Does TypeScript slow down development?
Initially, yes. Writing type definitions, fixing type errors, and learning advanced patterns takes time. After the learning curve, TypeScript accelerates development by catching bugs at compile time that otherwise would surface in testing or production. The IDE experience (autocomplete, inline documentation, refactoring tools) also improves significantly with TypeScript. Most teams report that TypeScript saves more debugging time than it costs in annotation effort within 2-3 months of adoption.
Building TypeScript interfaces from API data?
Use our free JSON to TypeScript converter to generate accurate interfaces from any JSON response. Paste your API data and get production-ready TypeScript types instantly.
Need TypeScript development expertise? The Debuggers provides TypeScript development services with strict mode enabled from the start.
Found this helpful?
Join thousands of developers using our tools to write better code, faster.