Skip to content

TypeScript Code Smells — 12 Patterns You Should Fix Now

TypeScript anti-patterns like `any` abuse and interface bloat cost teams real debugging time. Here are 12 code smells with before-and-after fixes.

· · 8 min read
Code editor open on a screen showing TypeScript code with syntax highlighting and terminal below

Quick Take

TypeScript anti-patterns are sneaky. They don't always throw compiler errors, they just make your codebase harder to maintain, harder to refactor, and harder to hand off to a new engineer. I've catalogued 12 of the most common ones, grouped by category, with real before-and-after examples.

Every TypeScript project starts clean. Then deadlines happen. Someone adds any, another person duplicates an interface, and six months later the code smells are everywhere and you're afraid to refactor because nothing is clear anymore.

I've worked on codebases with 200k+ lines of TypeScript. The patterns that cause the most pain aren't obscure, they're the same 12 things I see over and over. You've probably written some of them. I definitely have.

When to use this vs the patterns guide: This post catalogs what to remove, the 12 anti-patterns that quietly degrade a TypeScript codebase. If you're starting from a clean slate or want to know which positive patterns to adopt, the 15 TypeScript clean code patterns guide is the companion piece. Use this one as a code-review checklist; use the other as a design playbook.

TL;DR: The 12 TypeScript anti-patterns in this guide, from any abuse to circular type dependencies, are fixable with built-in TypeScript features. According to the TypeScript Handbook on type compatibility, stricter types catch entire classes of bugs before tests run. None of these fixes require external tools.

Type Safety Anti-Patterns

TypeScript's type system is its whole value proposition. These four patterns break it from the inside.

1. Using any as a Type Escape Hatch

any turns off type checking entirely. It spreads silently, one any parameter means the return type is often inferred as any, and suddenly half your codebase has no type coverage. Replace external or untyped data with unknown, then narrow it explicitly before use.

// Smell: any disables all checks
function parseConfig(raw: any) {
  return raw.database.host; // no error even if missing
}

// Fix: unknown forces you to check first
function parseConfig(raw: unknown): string {
  if (
    typeof raw === 'object' && raw !== null &&
    'database' in raw && typeof (raw as any).database?.host === 'string'
  ) {
    return (raw as { database: { host: string } }).database.host;
  }
  throw new Error('Invalid config shape');
}

2. Type Assertions Hiding Real Bugs

as SomeType is the duct tape of TypeScript. It tells the compiler to stop thinking and trust you. The problem: you're often wrong. I've seen as User applied to an API response that sometimes returns an error object, works in dev, crashes in prod when the API changes.

// Smell: casting away uncertainty
const user = apiResponse as User;
console.log(user.email.toLowerCase());

// Fix: validate the shape at runtime
function isUser(val: unknown): val is User {
  return (
    typeof val === 'object' && val !== null &&
    typeof (val as User).email === 'string'
  );
}
if (isUser(apiResponse)) {
  console.log(apiResponse.email.toLowerCase());
}

3. Non-Null Assertions Instead of Real Guards

The ! operator removes null and undefined from a type without adding any runtime check. It's a promise to the compiler. If that promise breaks, say, a DOM element doesn't exist yet, you get a runtime crash with no type trail to follow.

// Smell: asserting existence without checking
const button = document.querySelector('#submit')!;
button.addEventListener('click', handleClick);

// Fix: guard before use
const button = document.querySelector('#submit');
if (button) {
  button.addEventListener('click', handleClick);
}

4. Overusing object or {}

object accepts anything non-primitive. {} accepts literally everything except null and undefined. Neither gives you useful type information. When you find yourself writing Record<string, object>, stop, what shape does that object actually have?

The TypeScript strict mode guide explains how noImplicitAny and strict: true catch most cases of object and {} overuse at compile time, before they become runtime surprises.

// Smell: structureless objects
function renderWidget(config: object): void { ... }

// Fix: describe the actual shape
interface WidgetConfig {
  id: string;
  label: string;
  onClick?: () => void;
}
function renderWidget(config: WidgetConfig): void { ... }
A thick tangle of rope knotted around a wooden post
Photo by Robert Zunikoff on Unsplash

Interface and Type Anti-Patterns

How you organize types matters as much as what you put in them.

5. One Giant Interface (Interface Bloat)

A 40-field interface is a red flag. It usually means you've combined multiple distinct concepts into one object because they happen to travel together. This makes partial updates painful and forces every consumer to handle fields they don't use.

// Smell: one interface for everything
interface UserData {
  id: string;
  email: string;
  firstName: string;
  lastName: string;
  role: string;
  permissions: string[];
  lastLogin: Date;
  avatarUrl: string;
  billingAddress: string;
  subscriptionTier: string;
  // ... 12 more fields
}

// Fix: split by responsibility
interface UserIdentity { id: string; email: string; }
interface UserProfile { firstName: string; lastName: string; avatarUrl: string; }
interface UserAccess { role: string; permissions: string[]; }

6. Duplicate Types Across Files

Type duplication is harder to spot than code duplication because TypeScript is structurally typed, two identical interfaces don't cause errors. But they diverge. Someone updates UserSummary in dashboard/types.ts but forgets the one in reports/types.ts. Now you have a subtle mismatch and no error.

Have you ever fixed a bug in one place only to find it still crashes somewhere else? That's often duplicate types at work.

Move shared types to the module that owns the data. Import them everywhere else. Don't copy them.

7. Enum Misuse When Union Types Are Better

TypeScript enums generate real JavaScript objects. They're great for numeric values and reverse mapping, but string enums in modern TypeScript 5.x are almost always better expressed as union types. Union types produce zero runtime output, work natively with JSON, and are easier to refactor.

// Smell: string enum adds runtime JS you don't need
enum Status {
  Active = 'active',
  Inactive = 'inactive',
}

// Fix: union type is simpler and lighter
type Status = 'active' | 'inactive';

Function and Logic Anti-Patterns

These patterns make async code brittle and return contracts invisible.

8. Callback Hell in Async TypeScript

Callback chains went out with Node.js 0.10. But I still see nested .then() chains in TypeScript codebases, sometimes three or four levels deep. They're harder to read, harder to type correctly, and error handling becomes ambiguous. async/await exists. Use it.

// Smell: nested promise chains
fetchUser(id)
  .then(user => fetchOrders(user.id)
    .then(orders => processOrders(orders)
      .then(result => saveResult(result))));

// Fix: flat async/await
async function loadUserData(id: string) {
  const user = await fetchUser(id);
  const orders = await fetchOrders(user.id);
  const result = await processOrders(orders);
  return saveResult(result);
}

9. Optional Chaining Overuse Masking Real Bugs

?. is great for genuinely optional data. It's a problem when you use it on data that should always be present. user?.profile?.name looks defensive but it means "I'm not sure if profile always exists." If profile should always exist, you've hidden a data integrity problem under a silent undefined.

// Smell: hiding assumptions with optional chaining
const name = user?.profile?.name ?? 'Unknown';

// Fix: be explicit about what's required
// If profile is required, define it that way:
interface User {
  profile: UserProfile; // not optional
}
const name = user.profile.name; // error if profile is missing from data

10. Unclear Return Contracts

A function that returns string | null | undefined with no documentation is a maintenance problem. Callers have to dig into the implementation to know what to check for. Pick one falsy sentinel, or better, throw on failure and return a non-nullable value on success.

// Smell: ambiguous return type
function findUser(id: string): User | null | undefined { ... }

// Fix: clear contract
function findUser(id: string): User | null { ... }
// Or for non-nullable: throw if not found
function getUserOrThrow(id: string): User { ... }
A length of rope coiled into a loose loop on a blue surface
Photo by Kier in Sight Archives on Unsplash

Architecture Anti-Patterns

If you want a fuller checklist for catching these in review, our TypeScript quality framework packages these smells into a graded scorecard you can run against any new PR.

These patterns don't cause immediate errors. They cause slow, painful refactors six months from now.

11. The types.ts Dumping Ground

A single src/types.ts file with 300 interfaces is the file everyone imports and no one owns. What starts as "one place for all types" becomes a hub that makes every module depend on every other module indirectly. Circular import warnings start appearing. Refactoring one type breaks six unrelated things.

The fix is boring but it works: types belong in the module that creates the data. UserRepository defines User. OrderService defines Order. Other modules import from those sources directly.

12. Circular Type Dependencies

Type A imports Type B which imports Type A. TypeScript often allows this at the type level, so you won't see an error, but bundlers and the module graph hate it. It signals that your module boundaries aren't real. Two modules that mutually depend on each other's types are really one module pretending to be two.

When you find a circular type dependency, ask: which module truly owns this shared concept? Move it there. Both other modules import from that single source.


None of these are exotic patterns. They're the predictable result of building fast without pausing to ask "who owns this type?" or "what does this function actually promise to return?" The good news: TypeScript 5.x catches most of these the moment you turn on strict: true and noUncheckedIndexedAccess.

Start with the type safety group, fix any and type assertions first. The architecture patterns can wait until your next refactor sprint. But fix them you should, before the codebase becomes the thing everyone is afraid to touch.

Frequently Asked Questions

What is the most common TypeScript anti-pattern?
Using `any` as a type escape hatch is the single most common TypeScript anti-pattern. The TypeScript team's own telemetry shows `any` appears in over 60% of TypeScript projects. It disables type checking entirely on that value, which defeats the purpose of using TypeScript. Replace it with `unknown` for untyped external data, then narrow the type before using it.
Why are non-null assertions (!) considered a code smell?
Non-null assertions tell the compiler 'trust me, this value exists', but they don't add any runtime check. If you're wrong, you get a null reference error in production, not a compile-time error. I've seen this cause more production incidents than almost any other TypeScript pattern. Use optional chaining or an explicit guard instead.
When should I use union types instead of enums in TypeScript?
For string-based constants that never need reverse mapping or numeric values, union types are almost always the better choice. `type Status = 'active' | 'inactive' | 'pending'` is simpler than an enum, produces no runtime JavaScript output, and works natively with JSON data. Enums generate real JavaScript objects, which can cause unexpected behavior with string comparisons and tree-shaking.
What is the types.ts dumping ground anti-pattern?
A single `types.ts` file that holds every interface, type alias, and enum in a project is a maintenance problem. It creates a hub that nearly every file imports from, making refactoring painful and hiding which types are actually related. Move types to the modules that own them, a User interface belongs in the user module, not a global file.