Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 100 / 100

100 days of JavaScript and TypeScript: lessons explained

Wrapping up the series.

Expona Admin8 min read

After 100 days of writing, shipping, and debugging JavaScript and TypeScript, I've learned that language features are only half the story. The runtime, the type system, and how you structure boundaries between safe and unsafe code determine whether your application thrives or crumbles under load.

This is my wrap-up for 100 days of JavaScript and TypeScript: lessons—a distillation of what actually matters when you're building production systems.

The runtime matters — Async bugs and performance issues live in JavaScript semantics

JavaScript's single-threaded, event-driven runtime is both its superpower and a minefield. I've spent countless hours tracking down race conditions, unhandled rejections, and mysterious memory leaks that only surface under production load. The thing is, your TypeScript compiler can't catch any of it.

Type safety gives you confidence about what your code does. The runtime decides when and how often it runs. A function that's perfectly typed can still deadlock if you don't understand the event loop. A Promise chain that looks clean can leak memory if you forget to unsubscribe from an observable.

I've learned to think about concurrency as a first-class concern. Early on, I'd write code like this:

typescript
async function fetchUserWithOrders(userId: string) {
  const user = await db.users.findById(userId);
  const orders = await db.orders.findByUserId(userId);
  return { user, orders };
}

That works. But it makes two sequential database calls when they could run in parallel. Under load, that becomes a bottleneck. Once I started thinking about the runtime—about what's actually happening on the call stack—I'd rewrite it:

typescript
async function fetchUserWithOrders(userId: string) {
  const [user, orders] = await Promise.all([
    db.users.findById(userId),
    db.orders.findByUserId(userId),
  ]);
  return { user, orders };
}

Same types. Completely different performance profile. The runtime is unforgiving about these details, and TypeScript won't tell you when you've made a mistake. You only find out in production.

I've also learned to be paranoid about error handling in async code. An unhandled rejection that gets swallowed can leave your application in an inconsistent state. I now treat Promise-based code with the same rigor I'd use for synchronous exception handling:

typescript
// Bad: rejection silently ignored if not awaited
functionReturningPromise().catch(() => {});

// Good: explicit about what we're doing
try {
  await functionReturningPromise();
} catch (error) {
  logger.error('Operation failed', error);
  throw; // re-throw unless you have a specific recovery strategy
}

Types are design — Good types make invalid states impossible

The best types I've written aren't ornamental. They're structural. They embed business logic into the type system so that certain bugs become impossible to express, let alone compile.

Early in my career, I'd use generic strings and numbers everywhere. A user ID is a string. An order ID is a string. A timestamp is a number. Then I'd accidentally pass a user ID where an order ID was expected, and the bug would only surface at runtime.

Branded types changed how I think about this:

typescript
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };

function createUserId(id: string): UserId {
  return id as UserId;
}

function fetchUserOrders(userId: UserId): Promise {
  // This function can only accept a UserId
  return db.orders.find({ userId });
}

// Usage
const userId: UserId = createUserId('user-123');
fetchUserOrders(userId); // ✓ works

const orderId: OrderId = createOrderId('order-456');
fetchUserOrders(orderId); // ✗ compile error

That extra layer of type discipline feels like overhead until you've shipped a bug that a branded type would have caught. In a codebase with dozens of ID types floating around, branded types act as compile-time guards that prevent whole categories of mistakes.

I've also learned that discriminated unions are one of the most underrated TypeScript features. They make it impossible to enter invalid states:

typescript
type Result = 
  | { status: 'pending' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

function handleResult(result: Result) {
  switch (result.status) {
    case 'pending':
      return 'Loading...';
    case 'success':
      // TypeScript knows result.data exists here
      return result.data;
    case 'error':
      // TypeScript knows result.error exists here
      return result.error.message;
  }
}

Compare that to a naive approach where you'd have optional fields scattered across an object. With discriminated unions, the compiler forces you to handle every branch and guarantees that you can only access fields that are valid in that branch.

Boundaries need validation — Types end where the outside world begins

This took me years to fully internalize: TypeScript types are a compile-time fiction. They vanish when your code runs. The moment data crosses a boundary—from a network request, a database, a file system, or a user input—TypeScript can't protect you anymore.

I used to write API handlers like this:

typescript
interface CreateUserRequest {
  name: string;
  email: string;
  age: number;
}

app.post('/users', (req, res) => {
  const user = req.body as CreateUserRequest; // WRONG
  await db.users.create(user);
  res.json(user);
});

That as cast is a lie. req.body could contain anything. A malicious client could send { name: 123, email: [], age: "not a number" }, and your code would happily accept it. You've bought yourself a runtime crash or data corruption.

The right approach is runtime validation at the boundary:

typescript
import { z } from 'zod';

const CreateUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  age: z.number().int().positive(),
});

type CreateUserRequest = z.infer;

app.post('/users', (req, res) => {
  const parsed = CreateUserSchema.safeParse(req.body);
  
  if (!parsed.success) {
    return res.status(400).json({ errors: parsed.error.flatten() });
  }
  
  await db.users.create(parsed.data); // Now we're safe
  res.json(parsed.data);
});

This is a pattern I now apply everywhere. API inputs. Database reads. File uploads. Environment variables. Anywhere data enters your system, I add a validation layer. It's not glamorous, but it's the difference between a resilient system and one that fails unpredictably.

Simplicity scales — Plain functions and clear types beat clever abstractions

I've rewritten a lot of code in 100 days. The rewrites that made the biggest difference weren't the ones that added features—they were the ones that deleted code.

Early in my career, I'd try to build frameworks. Generic base classes. Higher-order functions that abstract away repetition. Dependency injection containers. Complex state machines. Some of it was clever. None of it scaled. As the codebase grew, the abstractions became opaque. New team members spent weeks learning framework patterns before they could ship code. Bugs lived in the layers between abstractions.

The turning point came when I started working with teams that valued plain functions and explicit types. A handler that's 50 lines of straightforward code beats a handler that's 10 lines of abstracted magic. You can read it. You can modify it. You can debug it without needing to trace through five layers of indirection.

Here's a simplified example. This is something I might have written three years ago:

typescript
abstract class Repository {
  abstract find(id: string): Promise;
  abstract create(entity: T): Promise;
  async findOrCreate(id: string, factory: () => T): Promise {
    try {
      return await this.find(id);
    } catch {
      return await this.create(factory());
    }
  }
}

class UserRepository extends Repository {
  find(id: string) { /* ... */ }
  create(entity: User) { /* ... */ }
}

Now here's how I'd write it today:

typescript
async function getOrCreateUser(
  userId: string,
  factory: () => User,
): Promise {
  const existing = await db.users.findById(userId);
  if (existing) return existing;
  return db.users.create(factory());
}

It's more code when you count the repository class, its tests, and the inheritance hierarchy. But in terms of code that ships features, it's vastly simpler. There are no abstractions to learn. The function does one thing. The types are clear.

I've learned to ask: "Does this abstraction reduce the amount of code I write, or does it just move it somewhere else?" Most of the time, it's the latter.

Common mistakes

  • Casting as validation: Using as or as const to force types at API boundaries instead of runtime validation. Your code will compile and then crash.
  • Ignoring Promise rejections: Awaiting promises without try-catch or `.catch()` handlers. Rejection errors silently disappear and leave your application in an inconsistent state.
  • Sequential instead of concurrent async: Writing await a(); await b(); when both are independent. The runtime will serialize them unnecessarily.
  • Over-engineering abstractions: Building frameworks and base classes before you've solved the problem three times. Abstraction is a tool for reducing repetition, not a design goal.
  • Forgetting that types are compile-time only: Treating TypeScript as if it validates at runtime. It doesn't. Everything that touches the outside world needs explicit validation.

Key takeaways

  • The JavaScript runtime is unforgiving. Understand the event loop, Promise semantics, and async/await scheduling. TypeScript won't catch performance or concurrency bugs.
  • Good types embed business logic and prevent invalid states from being representable. Use branded types and discriminated unions to make certain bugs impossible.
  • Validation is a runtime concern, not a type concern. Every boundary where external data enters your system needs explicit schema validation.
  • Simplicity compounds. Plain functions and straightforward types scale better than clever abstractions. Optimize for readability and debuggability before you optimize for code count.
  • Combine all four: understand the runtime, design types that prevent mistakes, validate at boundaries, and favor simplicity. That's the foundation of production systems that last.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles