TypeScript vs JavaScript: What 8 Years in the Trenches Taught Me

TypeScript vs JavaScript: What 8 Years in the Trenches Taught Me

I spent three hours last Tuesday debugging a billing microservice written back in 2019. Pure JavaScript. A webhook payload was missing a nested customerId property, and the application just swallowed it. It passed the undefined value through four different files until a database constraint finally threw a cryptic error 40 lines down the stack.

Well, that’s not entirely accurate — if that codebase had been TypeScript, my editor would have drawn a squiggly red line under the variable before I even hit save. That’s exactly the kind of thing that makes you want to throw your laptop out the window.

The debate between TypeScript and vanilla JavaScript usually devolves into theoretical arguments about compiler theory. I don’t care about that. I care about what happens at 2 AM when production is down. And after migrating three massive enterprise apps and writing countless small scripts, my perspective on when to use which has shifted entirely.

The Mental Model Shift

JavaScript is inherently trusting. You pass it a variable, and it assumes you know what you’re doing. TypeScript is a paranoid colleague who demands to see your ID before letting you in the building.

Let’s look at a dead-simple function. In vanilla JS, you write something like this:

function processPayment(user, amount) {
  if (user.isPremium) {
    return amount * 0.9;
  }
  return amount;
}

// Works fine until someone does this:
processPayment({ premium: true }, "100"); // Returns "100" instead of 90

JavaScript doesn’t care that you passed a string for the amount or that you misspelled isPremium as premium. It just silently fails to apply the discount.

Here is how that same logic looks when you actually define your data structures first:

interface User {
  id: string;
  isPremium: boolean;
  email: string;
}

function processPayment(user: User, amount: number): number {
  if (user.isPremium) {
    return amount * 0.9;
  }
  return amount;
}

// TypeScript catches this immediately:
// Error: Argument of type '{ premium: boolean; }' is not assignable to parameter of type 'User'.
processPayment({ premium: true }, 100);

The TS version forces you to document your assumptions. You aren’t just writing logic; you are designing a contract.

Async Operations and API Nightmares

Fetching data from an external API is where vanilla JavaScript usually falls apart in large applications. You hit an endpoint, get a JSON blob back, and just sort of hope the structure matches what you remember.

async function fetchUserProfile(userId) {
  try {
    const response = await fetch(https://api.example.com/users/${userId});
    const data = await response.json();
    
    // We are flying blind here. Does data.company exist? 
    // Is it data.company.name or data.companyName?
    console.log(Working at ${data.company.name});
    return data;
  } catch (error) {
    console.error("Failed to fetch", error);
  }
}

The dreaded Cannot read properties of undefined (reading 'name') error usually stems from this exact pattern. The API changes, or a specific user doesn’t have a company assigned, and your app crashes.

With TypeScript, I map out the expected response. This doesn’t validate the data at runtime (a common misconception—you still need something like Zod for that), but it gives you guardrails during development.

interface Company {
  id: number;
  name: string;
}

interface UserProfile {
  id: string;
  username: string;
  company?: Company; // The question mark means this might be undefined
}

async function fetchUserProfile(userId: string): Promise<UserProfile | null> {
  try {
    const response = await fetch(https://api.example.com/users/${userId});
    if (!response.ok) throw new Error('Network response was not ok');
    
    const data = (await response.json()) as UserProfile;
    
    // TypeScript forces us to check if company exists before accessing name
    if (data.company) {
      console.log(Working at ${data.company.name});
    }
    
    return data;
  } catch (error) {
    console.error("Failed to fetch", (error as Error).message);
    return null;
  }
}

The DOM Manipulation Headache

If you’re working on the frontend, TypeScript can feel incredibly pedantic when dealing with the Document Object Model. JavaScript lets you grab an element and do whatever you want.

// Vanilla JS
const submitBtn = document.getElementById('submit-form');
const emailInput = document.querySelector('.email-field');

submitBtn.addEventListener('click', () => {
  console.log("Sending email:", emailInput.value);
});

Simple. Clean. But what if the ID changes in the HTML? What if the query selector misses?

TypeScript knows that getElementById might return null. It also knows that a generic Element doesn’t necessarily have a .value property (a div doesn’t, an input does). You have to explicitly narrow the types.

// TypeScript
const submitBtn = document.getElementById('submit-form');
// We have to tell TS exactly what kind of element this is
const emailInput = document.querySelector<HTMLInputElement>('.email-field');

if (submitBtn && emailInput) {
  submitBtn.addEventListener('click', () => {
    // Now TS knows emailInput is definitely an input element with a value
    console.log("Sending email:", emailInput.value);
  });
}

The Verdict

My rule is pretty strict these days. If a project is going to live longer than a month, or if more than one person is going to touch the code, it gets written in TypeScript.

The trade-off here is speed vs. sanity. You pay a tax upfront in configuration and typing out interfaces. In return, you stop chasing undefined errors through ten layers of callbacks. I’ll gladly pay that tax.

Zahra Al-Farsi

Learn More →

Leave a Reply

Your email address will not be published. Required fields are marked *