TypeScript Mistakes Every Developer Makes (And How to Fix Them)

June 2, 2026

TypeScript Mistakes Every Developer Makes (And How to Fix Them)

TypeScript has rapidly become the industry standard for front-end and full-stack development, promising to bring type safety, robust refactoring, and self-documenting code to the dynamic world of JavaScript. When used correctly, TypeScript acts as a strict but helpful pair programmer, catching bugs before they reach production.

However, many developers treat TypeScript as a set of rules to be bypassed rather than a system to cooperate with. By using type escape hatches, abusing assertions, or over-engineering generic signatures, teams end up with a codebase that compiles without errors but fails at runtime anyway. In this deep-dive article, we will examine the most common architectural and syntactic TypeScript mistakes, explore why they happen, and implement professional, type-safe solutions.


1. Overusing the any Escape Hatch and the Solution of unknown

The any type is a compiler opt-out button. When you mark a variable as any, you tell the compiler to completely disable type-checking for that variable and any subsequent expressions it touches. This is essentially turning TypeScript back into raw JavaScript.

// Unsafe practice: Disables all compiler safeguards
function processUserData(user: any) {
  // If user or user.settings is undefined, this crashes at runtime
  const theme = user.settings.theme.toLowerCase();
  console.log(`Setting theme to: ${theme}`);
}

If you do not know the type of incoming data (for example, from a third-party API or dynamically parsed file), using any introduces immediate runtime vulnerability. Instead, use the unknown type. The unknown type represents any value, but TypeScript forces you to perform type narrowing or validation before you can perform operations on it.

Type Narrowing with User-Defined Type Guards

To work with unknown variables safely, you can create a custom type guard function using the is keyword:

interface UserProfile {
  id: string;
  settings: {
    theme: string;
  };
}

// User-defined type guard
function isUserProfile(value: unknown): value is UserProfile {
  if (typeof value !== 'object' || value === null) {
    return false;
  }
  
  const candidate = value as Record<string, unknown>;
  
  if (typeof candidate.id !== 'string') {
    return false;
  }
  
  if (typeof candidate.settings !== 'object' || candidate.settings === null) {
    return false;
  }
  
  const settingsCandidate = candidate.settings as Record<string, unknown>;
  return typeof settingsCandidate.theme === 'string';
}

function processSafeUserData(rawInput: unknown) {
  if (isUserProfile(rawInput)) {
    // Inside this block, TypeScript knows rawInput is a UserProfile
    const theme = rawInput.settings.theme.toLowerCase();
    console.log(`Setting theme to: ${theme}`);
  } else {
    console.error("Invalid user profile format received");
  }
}

Using unknown instead of any shifts the responsibility of type safety from the compiler back to your runtime logic, ensuring that your application handles unexpected payloads gracefully.


2. Abusing Type Assertions (as) and How Runtime Parsing Saves Applications

Type assertions (as) are another way of telling the compiler: "I know more than you do, trust me." While they are occasionally necessary when dealing with DOM APIs or legacy libraries, developers frequently abuse them to quickly silence compilation errors.

interface DBUser {
  id: string;
  email: string;
  role: 'admin' | 'editor' | 'viewer';
}

// Highly unsafe: Assumes the parsed JSON perfectly matches DBUser
const fetchUserFromAPI = async (id: string): Promise<DBUser> => {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json();
  return data as DBUser; 
};

If the API payload changes or returning values are empty, the returned object will not match DBUser, resulting in runtime exceptions like Cannot read properties of undefined later in your application flow.

Replacing Assertions with Runtime Validation (Zod)

Instead of using unsafe type assertions, you should parse and validate incoming data using a schema validation library like Zod. This guarantees that your types and runtime data are perfectly aligned.

import { z } from 'zod';

// Define the runtime schema
const DBUserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  role: z.union([
    z.literal('admin'),
    z.literal('editor'),
    z.literal('viewer')
  ])
});

// Infer the TypeScript type from the schema
type DBUser = z.infer<typeof DBUserSchema>;

const fetchSafeUserFromAPI = async (id: string): Promise<DBUser> => {
  const response = await fetch(`/api/users/${id}`);
  const rawData = await response.json();
  
  // Enforces structural integrity at runtime, throwing if invalid
  const validatedUser = DBUserSchema.parse(rawData);
  return validatedUser;
};

By switching from type assertions to schema validation, you establish a strong perimeter at the boundaries of your application, ensuring that incorrect external data is rejected immediately.


3. Over-Engineering Generics vs Simple Interface Polymorphism

Generics are an incredibly powerful feature of TypeScript, enabling code reusability. However, a common mistake is over-engineering generic signatures when a simple interface or union type would be cleaner and easier to read.

Consider this over-engineered generic function:

// Unnecessarily complex generic signature
function getRecordId<T extends { id: string }>(record: T): string {
  return record.id;
}

There is no benefit to using generics here because the function does not map the type of the argument directly to the return type in a dynamic way. The return type is always a string. We can simplify this significantly using structural polymorphism:

interface Identifiable {
  id: string;
}

// Clean and readable alternative
function getRecordIdSimple(record: Identifiable): string {
  return record.id;
}

When to Actually Use Generics

You should use generics only when there is a clear relationship between the types of different parameters, or between the parameters and the return type. For example:

// Proper use of generics: Maps the input array items to a specific transformation result
function mapRecord<T, U>(items: T[], transformer: (item: T) => U): U[] {
  return items.map(transformer);
}

Before adding a <T> to your function, ask yourself if you need to use the generic type parameter to relate two or more elements. If not, stick to standard interfaces.


4. Misunderstanding Structural Typing and the Excess Property Trap

JavaScript is inherently dynamic, and TypeScript models this behavior using a structural type system (often called duck typing). If two objects have the same shape, they are considered to be of the same type, regardless of how they are instantiated.

interface Point2D {
  x: number;
  y: number;
}

function printCoordinates(point: Point2D) {
  console.log(`x: ${point.x}, y: ${point.y}`);
}

const point3D = { x: 10, y: 20, z: 30 };
printCoordinates(point3D); // Compiles perfectly!

Because point3D contains both x and y, it satisfies the structural contract of Point2D. However, TypeScript behaves differently when you pass object literals directly to a function (excess property checking):

// Error: Object literal may only specify known properties, and 'z' does not exist in 'Point2D'
printCoordinates({ x: 10, y: 20, z: 30 });

This strict checking is designed to catch typos. But it can create confusion for developers who do not understand why assigning the object to a variable first removes the error.

Using Index Signatures Safely

If your object needs to support extra properties dynamically, you should define an index signature or use structural extensions:

interface DynamicPoint {
  x: number;
  y: number;
  [key: string]: unknown; // Explicitly allows other properties
}

function printDynamicCoordinates(point: DynamicPoint) {
  console.log(`x: ${point.x}, y: ${point.y}`);
}

// Compiles successfully even with raw literal
printDynamicCoordinates({ x: 10, y: 20, z: 30 });

5. Optional Properties Everywhere vs Discriminated Unions

When modeling state, developers often create a single monolithic interface where every property is optional to handle different stages of an asynchronous process.

// Unsafe state representation
interface FetchState {
  data?: string;
  error?: string;
  isLoading: boolean;
}

This design allows for impossible states, such as having both data and error present simultaneously, or having isLoading: false but both data and error missing. Code that consumes this state will require complex nested checking to determine what is actually available.

// Consumption code becomes error-prone
function renderState(state: FetchState) {
  if (state.isLoading) {
    return "Loading...";
  }
  if (state.error) {
    return `Error: ${state.error}`;
  }
  if (state.data) {
    return `Data: ${state.data}`;
  }
  return "Unknown state"; // Code smell
}

Designing State Machines with Discriminated Unions

A far better approach is to use a discriminated union. By introducing a shared literal property (a discriminant), we can split the state into mutually exclusive types:

type FetchState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: string }
  | { status: 'error'; error: Error };

function renderSafeState(state: FetchState) {
  switch (state.status) {
    case 'idle':
      return "Ready to load";
    case 'loading':
      return "Loading...";
    case 'success':
      // TypeScript automatically narrows state to Success type here
      return `Data: ${state.data}`;
    case 'error':
      // TypeScript automatically narrows state to Error type here
      return `Error: ${state.error.message}`;
  }
}

This structural architecture ensures that it is impossible to instantiate the component in an invalid state, removing boilerplate checking and simplifying your business logic.


6. The Flaws of the Built-In Omit Utility and Implementing StrictOmit

TypeScript includes utility types like Pick, Omit, and Exclude. However, the built-in Omit type has a major flaw: it does not enforce that the key you want to omit actually exists on the target object.

interface UserConfig {
  theme: string;
  notifications: boolean;
  version: number;
}

// No warning, despite 'nonExistentKey' not being a property of UserConfig
type TruncatedConfig = Omit<UserConfig, 'version' | 'nonExistentKey'>;

This behavior makes code vulnerable to silent bugs during refactoring. If you rename a property on UserConfig, the Omit utility will continue to run without warning, creating stale mappings.

Defining and Using StrictOmit

We can write our own utility type that forces the key argument to be a subset of the target type's keys:

// Define StrictOmit using keyof assertions
type StrictOmit<T, K extends keyof T> = Omit<T, K>;

// This will fail compile-time checks, alerting you to the invalid key
type SafeConfig = StrictOmit<UserConfig, 'version' | 'nonExistentKey'>;
                   Comparison of Omit vs StrictOmit
                   
+------------------------------------+------------------------------------+
  Omit<UserConfig, 'invalidKey'>     | StrictOmit<UserConfig, 'invalidKey'>
+------------------------------------+------------------------------------+
  Compiles without errors            | Triggers compile-time error        
  Stale keys are ignored             | Forces correct key maintenance     
  Prone to silent refactoring bugs   | Guarantees structural integrity    
+------------------------------------+------------------------------------+

By using StrictOmit across your codebase, you ensure that future structural updates do not leave behind dead or mismatched mappings.


7. Mutability Side Effects: Missing readonly Modifiers

In clean architecture, functions should avoid mutating arguments to prevent side effects in other parts of the application. By default, properties and arrays in TypeScript are mutable, allowing bugs to creep into state management.

interface Cart {
  items: string[];
}

function clearCartItem(cart: Cart, index: number) {
  // Directly mutates the original object
  cart.items.splice(index, 1); 
}

To prevent unexpected mutations, declare your configurations and interfaces as read-only.

Using readonly Properties and ReadonlyArray

By applying readonly to properties and wrapping arrays in ReadonlyArray<T> (or using the shorthand readonly T[]), the compiler prevents mutating methods from being called:

interface ImmutableCart {
  readonly items: readonly string[];
}

function safeClearCartItem(cart: ImmutableCart, index: number): ImmutableCart {
  // cart.items.splice(index, 1); // Error: Property 'splice' does not exist on type 'readonly string[]'
  
  // Return a new copy instead of mutating
  return {
    items: cart.items.filter((_, idx) => idx !== index)
  };
}

Using read-only constructs forces developers to write pure functions, aligning the codebase with modern declarative and functional paradigms.

By avoiding these seven common pitfalls, you can transform your TypeScript code from a set of bypassed compile rules into a powerful, automated guarantee of runtime safety and code quality.