The Dark Side of TypeScript: When Types Make Your Code Worse

TypeScript can make your code better or worse. Learn about common mistakes, anti-patterns, and how to use TypeScript effectively in your projects.

TypeScript is amazing, but used incorrectly, it can make your codebase more complex and harder to maintain. Here's what to avoid and how to use TypeScript effectively.


Common Anti-Patterns

1. Type Explosion

// ❌ BAD: Type explosion
type UserBase = {
  id: string;
  name: string;
};

type UserWithEmail = UserBase & {
  email: string;
};

type UserWithAddress = UserBase & {
  address: string;
};

type UserWithPhone = UserBase & {
  phone: string;
};

type UserComplete = UserBase & 
  UserWithEmail & 
  UserWithAddress & 
  UserWithPhone;

// ✅ BETTER: Single, focused type
type User = {
  id: string;
  name: string;
  email?: string;
  address?: string;
  phone?: string;
};

2. Over-Engineering Types

// ❌ BAD: Unnecessarily complex
type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object
    ? DeepReadonly<T[P]>
    : T[P];
};

type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object
    ? DeepPartial<T[P]>
    : T[P];
};

type ComplexUser = DeepReadonly<DeepPartial<User>>;

// ✅ BETTER: Simple and clear
type User = {
  readonly id: string;
  name: string;
  email?: string;
};

Real World Problems

1. Type Assertion Hell

// ❌ BAD: Type assertion cascade
function processUser(data: unknown) {
  const user = data as any as User as UserWithRoles;
  return user as ProcessedUser;
}

// ✅ BETTER: Type guards and validation
function processUser(data: unknown): ProcessedUser {
  if (!isUser(data)) {
    throw new Error('Invalid user data');
  }
  return processValidUser(data);
}

function isUser(data: unknown): data is User {
  return (
    typeof data === 'object' &&
    data !== null &&
    'id' in data &&
    'name' in data
  );
}

2. Generic Abuse

// ❌ BAD: Overuse of generics
type QueryBuilder<
  T extends object,
  K extends keyof T,
  V extends T[K],
  R extends Record<string, unknown>
> = {
  where<NK extends K>(
    key: NK,
    value: T[NK]
  ): QueryBuilder<T, K, V, R & Record<NK, T[NK]>>;
  execute(): Promise<T[]>;
};

// ✅ BETTER: Simpler, focused approach
type Query<T> = {
  where(key: keyof T, value: unknown): Query<T>;
  execute(): Promise<T[]>;
};

Better Patterns

1. Smart Type Inference

// Let TypeScript work for you
const createUser = (data: CreateUserInput) => {
  const user = {
    id: generateId(),
    createdAt: new Date(),
    ...data,
  };
  
  // TypeScript infers the correct type
  return user;
};

// No need for explicit return type
const user = createUser({
  name: 'John',
  email: 'john@example.com',
});

2. Discriminated Unions

// Clear and maintainable
type Success<T> = {
  type: 'success';
  data: T;
};

type Error = {
  type: 'error';
  error: string;
};

type Result<T> = Success<T> | Error;

function handleResult<T>(result: Result<T>) {
  if (result.type === 'success') {
    // TypeScript knows result.data exists
    return result.data;
  } else {
    // TypeScript knows result.error exists
    throw new Error(result.error);
  }
}

Performance Impact

1. Type Compilation Overhead

// ❌ BAD: Complex type computations
type DeepNestedConditional<T> = T extends object
  ? {
      [P in keyof T]: T[P] extends object
        ? DeepNestedConditional<T[P]>
        : T[P] extends Array<infer U>
        ? Array<DeepNestedConditional<U>>
        : T[P];
    }
  : T;

// ✅ BETTER: Simple, flat types
type User = {
  id: string;
  name: string;
  settings: UserSettings;
};

type UserSettings = {
  theme: string;
  notifications: boolean;
};

2. Bundle Size Impact

// ❌ BAD: Runtime type checking
function validateUser(user: unknown): user is User {
  return (
    typeof user === 'object' &&
    user !== null &&
    'id' in user &&
    'name' in user &&
    typeof user.id === 'string' &&
    typeof user.name === 'string'
  );
}

// ✅ BETTER: Compile-time types only
type User = {
  id: string;
  name: string;
};

Practical Solutions

1. Type Organization

// Organize by domain
// types/user.ts
export type User = {
  id: string;
  name: string;
};

export type UserPreferences = {
  theme: 'light' | 'dark';
  notifications: boolean;
};

// Clear imports
import { User, UserPreferences } from './types/user';

2. Error Handling

// Type-safe error handling
type ApiError = {
  code: string;
  message: string;
};

type ApiResponse<T> = {
  data: T;
  error: null;
} | {
  data: null;
  error: ApiError;
};

async function fetchUser(id: string): Promise<ApiResponse<User>> {
  try {
    const user = await api.users.get(id);
    return { data: user, error: null };
  } catch (error) {
    return {
      data: null,
      error: {
        code: 'USER_NOT_FOUND',
        message: 'User not found',
      },
    };
  }
}

Best Practices

  1. Keep types simple and focused
  2. Use type inference when possible
  3. Avoid excessive type assertions
  4. Use discriminated unions for clarity
  5. Don't recreate runtime type checking
  6. Organize types by domain
  7. Use TypeScript for compile-time safety only

Conclusion

TypeScript is powerful but requires discipline:

  • Avoid over-engineering types
  • Let type inference work for you
  • Focus on maintainability
  • Keep types simple and clear
  • Use types for documentation

Remember: The best TypeScript code is often the simplest.