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. (11 min)
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.
Share this post & I’ll send you some rewards for the referrals.
Issues that fix themselves? (Sentry Seer, Partner)
Seer is Sentry’s AI debugging agent. It uses everything Sentry knows (errors, traces, logs, replays, profiles, and your code) to automatically fix production issues, investigate problems alongside you, and predict bugs before they ship.
Every error Sentry captures comes with a stack trace, a span tree, a breadcrumb trail, session replay, profiles, logs, and the surrounding code. Seer is a reasoning layer built specifically for debugging and wired directly into all of that telemetry.
Other AI tools can help you write code or search docs. Seer understands why your app is broken.
Autofix kicks in directly on issues in Sentry and in your Slack alerts.
Seer Agent lets you investigate proactively—ask questions, explore patterns, dig into incidents conversationally.
AI Code Review catches bugs before they ship, scanning PRs against production behavior.
Sentry’s MCP brings Seer’s context directly into your coding agent (Cursor, Copilot, Claude) in your editor or terminal.
PS: Use the tshapeddev promo code to get 3 months free on the Team plan of Sentry for any new accounts.
(Thanks to Sentry for partnering on this post.)
1. Not Enabling TypeScript Strict Mode
// 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:
{
"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. Using any as an Escape Hatch
// ❌ 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:
// ✅ 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
// ❌ 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:
// ✅ 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
// ❌ 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:
// ✅ 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
// ❌ 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):
// ✅ Strategy A — Rethrow (let the caller decide)
try {
await processPayment(order);
} catch (error) {
logger.error("Payment failed", { orderId: order.id, error });
throw error;
}// ✅ 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);
}// ✅ 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.
// ✅ 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 hereThis 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
// ❌ 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:
// ✅ 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
// ❌ 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:
// ✅ 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
// ❌ 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.
// ✅ 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
// ❌ 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.
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
// ❌ 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.
// 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
// ❌ 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 nullCircular 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:
// ✅ 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.
Thanks for reading, and stay awesome!
Follow me on LinkedIn | Twitter(X) | Threads
Thank you for supporting this newsletter.
Consider sharing this post with your friends and get rewards.
You are the best! 🙏




