All articlesJavaScript

20 Mistakes That Quietly Destroy JavaScript/TypeScript Codebases (Part 1)

Common JS/TS patterns that feel fine until they don't. 11 mistakes, before/after code for each.

Petar IvanovPetar Ivanov
14 min read
On this page

Every JS/TS codebase collects the same kinds of mistakes.

They don’t break the build. They don’t fail tests. They rarely show up in PR review. They just sit there, quietly making every change a little more expensive, until one of them surfaces as a production incident at 2am.

I’ve hit every one of these across 9+ years of shipping JavaScript and TypeScript. Twenty in total, too many for one post, so I split them into two parts.

This post covers the foundations: how you model types, how you handle errors, and how you shape your modules. Get these wrong and everything you build on top compounds the pain.

1. Not Enabling TypeScript Strict Mode

TypeScript
// tsconfig.json — the default sin
{
  "compilerOptions": {
    "strict": false // or just not setting it
  }
}

Without strict mode, TypeScript silently allows null where you expect a string, undefined where you expect an object, and implicit any on every untyped parameter. You get half the type safety and all of the tooling overhead.

The fix:

JSON
{
  "compilerOptions": {
    "strict": true
  }
}

This enables strictNullChecks, noImplicitAny, strictFunctionTypes, and more.

Yes, migrating an existing project hurts. Do it anyway. Enable strict flags incrementally if you must, but strictNullChecks alone prevents more runtime errors than everything else combined.


2. Usinganyas an Escape Hatch

TypeScript
// ❌ The "make TypeScript shut up" approach
function processData(data: any) {
  return data.items.map((item: any) => item.name.toUpperCase());
}

any disables type checking entirely. It’s contagious. Once any enters a chain, everything downstream is unchecked. You’ve just written JavaScript with extra build steps.

The fix:

TSX
// ✅ Use unknown + a type guard to narrow
function isValidResponse(data: unknown): data is { items: { name: string }[] } {
  if (typeof data !== "object" || data === null) return false;
  const maybe = data as { items?: unknown };
  if (!Array.isArray(maybe.items)) return false;
  return maybe.items.every(
    (item) =>
      typeof item === "object" &&
      item !== null &&
      typeof (item as { name?: unknown }).name === "string",
  );
}

function processData(data: unknown): string[] {
  if (!isValidResponse(data)) {
    throw new Error("Invalid data format");
  }
  // After the guard, TypeScript knows data.items exists and is typed
  return data.items.map((item) => item.name.toUpperCase());
}

// Or use generics when the type is truly variable
function firstElement<T>(arr: T[]): T | undefined {
  return arr[0];
}

unknown is the type-safe cousin of any. It forces you to narrow before accessing properties.

Type guards (the data is X return type, also called a type predicate) teach the compiler what shape to expect after the check.

Important: the compiler takes your word for it. If the guard body doesn't actually verify every field you claim, you get a typed lie.

For non-trivial shapes, reach for a schema library like Zod instead of hand-rolling guards.

Use generics when you need flexibility. Reserve any for genuinely untyped boundaries (third-party libs with no types), and mark those with // eslint-disable-next-line @typescript-eslint/no-explicit-any.


3. Not Using Discriminated Unions

TypeScript
// ❌ Optional field soup
interface ApiResponse {
  data?: UserData;
  error?: string;
  loading?: boolean;
}

// Is this valid? { data: undefined, error: "fail", loading: true }
// What about { data: someData, error: "also fail" }?
// The type allows both. Your runtime won't.

The fix:

TSX
// ✅ Discriminated union — each state is explicit
type ApiResponse =
  | { status: 'loading' }
  | { status: 'success'; data: UserData }
  | { status: 'error'; error: string };

function handleResponse(response: ApiResponse) {
  switch (response.status) {
    case 'loading':
      return <Spinner />;
    case 'success':
      return <UserProfile data={response.data} />;  // data is guaranteed
    case 'error':
      return <ErrorBanner message={response.error} />;  // error is guaranteed
  }
}

Discriminated unions make impossible states unrepresentable. TypeScript automatically narrows the type in each branch; no null checks needed.


4. Ignoring Return Types on Exported Functions

TypeScript
// ❌ No return type — the API contract is implicit
export async function getUser(id: string) {
  const user = await db
    .selectFrom("users")
    .where("id", "=", id)
    .selectAll()
    .executeTakeFirst();
  if (!user) throw new NotFoundError("User not found");
  return user;
}

TypeScript infers the return type, but the inference is tied to the implementation.

Change how you query the database and the return type silently changes, breaking every caller without a compile error.

The fix:

TSX
// ✅ Explicit return type on exported functions
export async function getUser(id: string): Promise<User> {
  const user = await db
    .selectFrom("users")
    .where("id", "=", id)
    .selectAll()
    .executeTakeFirst();
  if (!user) throw new NotFoundError("User not found");
  return user;
}

Explicit return types on exported functions serve as a contract. If the implementation changes in a way that breaks the contract, the function itself errors, not every downstream consumer.

Rule of thumb: always type exports. Let inference handle private/local functions.


5. Catching Errors and Swallowing Them

TypeScript
// ❌ The silent failure
try {
  await processPayment(order);
} catch (error) {
  console.log("Payment failed");
  // Cool, so... now what?
}

The order continues as if payment succeeded. The user gets charged nothing. You get no alert. The bug sits in production for weeks until someone checks the logs.

The fix: every catch block needs exactly one strategy.

Here are your three options (pick one, not all):

TypeScript
// ✅ Strategy A — Rethrow (let the caller decide)
try {
  await processPayment(order);
} catch (error) {
  logger.error("Payment failed", { orderId: order.id, error });
  throw error;
}
TypeScript
// ✅ Strategy B — Recover (handle it yourself)
try {
  await processPayment(order);
} catch (error) {
  logger.error("Payment failed", { orderId: order.id, error });
  await markOrderAsPendingPayment(order.id);
  await notifyPaymentTeam(order.id);
}
TypeScript
// ✅ Strategy C — Transform into a domain error
try {
  await processPayment(order);
} catch (error) {
  logger.error("Payment failed", { orderId: order.id, error });
  throw new PaymentFailedError(order.id, { cause: error });
}

“Log and continue” is almost never the right answer. If you’re not rethrowing, recovering, or transforming, you’re hiding bugs.

Strategy D: Make errors impossible to ignore with the Result pattern.

try/catch has a design flaw — nothing forces the caller to handle the error.

The Result pattern encodes success and failure in the return type itself. You can’t access the data without checking first.

TypeScript
// ✅ Result type — errors are values, not exceptions
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };

async function processPayment(
  order: Order,
): Promise<Result<Receipt, PaymentError>> {
  try {
    const receipt = await stripe.charges.create({ /* ... */ });
    return { ok: true, value: receipt };
  } catch (err) {
    return { ok: false, error: new PaymentError(order.id, { cause: err }) };
  }
}

// The caller CAN'T ignore the error — TypeScript won't let you access .value without checking .ok
const result = await processPayment(order);
if (!result.ok) {
  await markOrderAsPendingPayment(order.id);
  return;
}
sendConfirmation(result.value);  // result.value is typed as Receipt here

This works great for operations where failure is expected and the caller should decide what to do, like payments, validation, external API calls.

Save try/catch for truly exceptional cases (database down, out of memory).

Use Result for “this might not work and that’s fine”.


6. Not Handling Promise Rejections

TypeScript
// ❌ Fire and forget
app.post("/webhook", (req, res) => {
  processWebhook(req.body); // Returns a Promise — but nobody awaits it
  res.sendStatus(200);
});

If processWebhook throws, you get an unhandled rejection.

In Node.js 15+, that crashes the process. Even with a global handler, you’ve lost the request context.

The fix:

TypeScript
// ✅ Always await or explicitly handle
app.post("/webhook", async (req, res, next) => {
  try {
    await processWebhook(req.body);
    res.sendStatus(200);
  } catch (error) {
    next(error);  // Let Express error handler deal with it
  }
});

// If you truly need fire-and-forget:
processWebhook(req.body).catch((error) => {
  logger.error("Webhook processing failed", { error });
});

Rule: every Promise either gets awaited or gets a .catch(). No exceptions.


7. Hardcoding Dependencies Instead of Injecting Them

TypeScript
// ❌ Welded to implementations
import { db } from "../lib/db";
import { sendEmail } from "../lib/email";

export async function createOrder(data: CreateOrderInput) {
  const order = await db
    .insertInto("orders")
    .values(data)
    .returningAll()
    .executeTakeFirstOrThrow();
  await sendEmail(order.email, "Confirmed", template(order));
  return order;
}

This function is impossible to unit test without jest.mock('../lib/db'), and the moment you rename that file or move it, every mock breaks. You end up testing your mock wiring, not your business logic.

The fix:

TypeScript
// ✅ Dependencies come from the outside
interface OrderServiceDeps {
  db: Database;
  email: EmailService;
}

export function createOrderService({ db, email }: OrderServiceDeps) {
  return {
    async createOrder(data: CreateOrderInput) {
      const order = await db.orders.create(data);
      await email.send(order.email, "Confirmed", template(order));
      return order;
    },
  };
}

Now testing is trivial: pass in fakes. No jest.mock, no module path fragility. Wire real implementations at the entry point.

(For a deep dive, see Dependency Injection in Node.js & TypeScript. The Part Nobody Teaches You)


8. Over-Engineering with Microservices Too Early

Text
// ❌ 3 developers, 500 users, and this repo structure:
api-gateway/          ← own Docker, CI/CD, database
user-service/         ← own Docker, CI/CD, database
order-service/        ← own Docker, CI/CD, database
payment-service/      ← own Docker, CI/CD, database
notification-service/ ← own Docker, CI/CD, database
// 5 deploys, 5 sets of logs, 5 things to debug at 2am

You’re spending more time debugging network calls between your own services than building features. Every “simple” change touches three repos and needs coordinated deploys.

The fix: Start with a modular monolith. Same domain boundaries, one deploy.

Text
// ✅ Modular monolith — clear boundaries, zero network overhead
src/
  modules/
    users/        # own routes, service, repository, schema
    orders/       # own routes, service, repository, schema
    payments/     # own routes, service, repository, schema
    notifications/# own routes, service, repository, schema
  shared/         # cross-module types, utils, middleware

Each module owns its slice of the database and exposes a clean interface. No module reaches into another’s tables. When (not if) you need to extract a module into a service, the boundary is already there — it’s a deployment change, not a rewrite.

Extract to services only when you have a concrete scaling bottleneck — not because a conference talk said microservices are the future. Most teams that “need microservices” actually need better module boundaries in their monolith.


9. Writing 100+ Line Functions

TypeScript
// ❌ The god function
export async function handleCheckout(req: Request, res: Response) {
  // Validate input (20 lines)
  // Check inventory (15 lines)
  // Calculate pricing (25 lines)
  // Apply discount codes (20 lines)
  // Process payment (15 lines)
  // Create order (10 lines)
  // Send confirmation (10 lines)
  // Update analytics (10 lines)
  res.json({ orderId: order.id });
}

The fix: Extract each block into a named function, not for reuse, but for readability.

TypeScript
export async function handleCheckout(req: Request, res: Response) {
  const input = validateCheckoutInput(req.body);
  await checkInventory(input.items);
  const pricing = calculatePricing(input.items, input.discountCode);
  const charge = await processPayment(pricing, input.paymentMethod);
  const order = await createOrder(input, pricing, charge);
  await sendConfirmation(order);
  trackCheckout(order);
  res.json({ orderId: order.id });
}

Each function name tells you what happens. You can read the checkout flow in 10 seconds without scrolling through 125 lines of implementation.


10. Putting Business Logic in Controllers

TypeScript
// ❌ Controller knows too much
app.post("/orders", async (req, res) => {
  const items = req.body.items;
  let total = 0;
  for (const item of items) {
    const product = await db
      .selectFrom("products")
      .where("id", "=", item.productId)
      .selectAll()
      .executeTakeFirstOrThrow();
    if (product.stock < item.quantity) {
      return res.status(400).json({ error: "Out of stock" });
    }
    total += product.price * item.quantity;
  }
  if (req.body.discountCode) {
    const discount = await db
      .selectFrom("discounts")
      .where("code", "=", req.body.discountCode)
      .selectAll()
      .executeTakeFirstOrThrow();
    total *= 1 - discount.percentage / 100;
  }
  const order = await db
    .insertInto("orders")
    .values({ items, total, userId: req.user.id })
    .returningAll()
    .executeTakeFirstOrThrow();
  res.json(order);
});

Now your business rules are trapped in an HTTP handler. Can’t reuse them for a CLI, a queue consumer, or a GraphQL resolver.

The fix: Controllers do three things: parse input, call a service, format output.

TypeScript
// Controller — thin
app.post("/orders", async (req, res) => {
  const input = parseCreateOrderInput(req.body);
  const order = await orderService.createOrder(input, req.user.id);
  res.json(formatOrderResponse(order));
});

// Service — contains business logic
class OrderService {
  async createOrder(input: CreateOrderInput, userId: string) {
    await this.validateInventory(input.items);
    const total = this.calculateTotal(input.items, input.discountCode);
    return this.db.orders.create({ ...input, total, userId });
  }
}

11. Circular Dependencies Between Modules

TypeScript
// ❌ user.ts imports order.ts, order.ts imports user.ts
// user.ts
import { Order } from "./order";
export class User {
  orders: Order[] = [];
}

// order.ts
import { User } from "./user";
// Module top-level use of the cyclic import — User is still undefined here
// because user.ts hasn't finished evaluating yet.
export class Order extends User {}
// 💥 TypeError: Class extends value undefined is not a constructor or null

Circular imports are silent killers.

When the runtime hits a cycle, it hands the second module a half-initialized copy of the first. The bindings exist, but their values are still undefined.

Use those bindings at the module top level (extends, new, calling a function, reading a constant) and you get a crash. Use them only inside a method body, and it usually "works", until someone adds a top-level reference and the file blows up on import.

The stack trace rarely points to the real cause.

The fix:

TypeScript
// ✅ Option 1: Extract the shared contract into a third module
// types.ts
export interface IUser { name: string; orders: IOrder[]; }
export interface IOrder { owner: IUser; }

// user.ts — depends on types, not on order.ts
import { IOrder } from "./types";
export class User implements IUser {
  name!: string;
  orders: IOrder[] = [];
}

// order.ts — depends on types, not on user.ts
import { IUser } from "./types";
export class Order implements IOrder {
  owner!: IUser;
}

// ✅ Option 2: Break the cycle by inverting the dependency direction.
// "Lower-level" modules shouldn't reach up into "higher-level" ones.
// Orders don't need the whole User — pass what they actually use.
export class Order {
  constructor(public ownerName: string) {}
}

If two modules import each other, one of them knows too much. Fix the dependency direction or extract the shared contract into a third module.

Types alone (interface, type) are erased at compile time and don’t cause runtime cycles, so moving the shared types into a separate file is the cheapest way out.

Turn on eslint-plugin-import‘s no-cycle rule so new cycles fail CI instead of hiding until the wrong person imports the wrong file.


📌 TL;DR

In this post (Part 1), we’ve covered the foundational mistakes that shape everything else:

  • Type Safety: strict mode, any, discriminated unions, return types.
  • Error Handling: swallowing errors, unhandled rejections, the Result pattern.
  • Architecture & Design: dependency injection, microservices, long functions, controllers, circular deps.

In Part 2, we will cover runtime and quality mistakes that bite you in production like Code Hygiene, Async & Performance, Testing & Validation.

Related articles

Whenever you’re ready, here’s how I can help you:

  1. 1.

    The Conscious React: React architecture, design & clean code — 100+ production tips across 6 chapters, updated for React 19, plus 4 companion repos you can clone and run.

  2. 2.

    The Conscious Node: Node.js architecture, design & clean code — 157 production tips across 10 chapters, from module boundaries to the transactional outbox and zero-downtime deploys.

  3. 3.

    The JavaScript Architect Bundle: Both books + all React companion repos + CLAUDE.md rulesets + both playbooks. The complete path from developer to architect.

  4. 4.

    Free Resources: Architecture playbooks, cheat-sheets, and the JavaScript Architect Roadmap — practical guides for leveling up to senior.

The T-Shaped Dev

Join 30K+ engineers leveling up to architect

One practical tip on JavaScript, React, Node.js, and software architecture every week. No spam, unsubscribe anytime.

Petar Ivanov

Written by

Petar Ivanov

Software engineer, author, and speaker. I help JavaScript developers grow from Mid → Senior → Architect — production-grade React, Node.js, and AI systems.