Web Development

TypeScript Common Mistakes Senior Developers Still Make

The Debuggers Engineering Team
11 min read

TL;DR

  • Replace every any with unknown and a type guard. any disables type checking and propagates silently
  • Discriminated unions eliminate entire categories of runtime errors that type assertions mask
  • Enums have surprising runtime behaviour. Prefer as const objects or string literal unions
  • Enable every strict flag in tsconfig. Each one catches a different class of bugs

Table of Contents

TypeScript code with type annotations on a dark code editor

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).

Code review showing type safety patterns and anti-patterns

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.

TypeScript configuration file showing strict mode settings

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:

FlagWhat It Catches
strictNullChecksPrevents accessing properties on possibly null/undefined values
strictFunctionTypesCatches function parameter type variance bugs
strictBindCallApplyType-checks bind, call, and apply method arguments
strictPropertyInitializationForces class properties to be initialised in the constructor
noImplicitAnyRequires explicit types where TypeScript cannot infer
noImplicitThisFlags functions where this type is unclear
alwaysStrictEmits "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.

Need Help Implementing This in a Real Project?

Our team supports end-to-end development for web and mobile software, from architecture to launch.

typescript common mistakestypescript best practicestypescript any typetypescript generics mistakestypescript strict mode

Found this helpful?

Join thousands of developers using our tools to write better code, faster.