TypeScript: Stop Writing JavaScript With Extra Steps

TypeScript: Stop Writing JavaScript With Extra Steps

# typescript# webdev# javascript# cleancoding
TypeScript: Stop Writing JavaScript With Extra StepsAishwarya B R

Production is down. You're staring at Cannot read property 'email' of undefined for the 47th time...

Production is down. You're staring at Cannot read property 'email' of undefined for the 47th time this quarter. The user object was supposed to have an email. The API docs said it would have an email. Your colleague who left six months ago definitely said it would have an email.

But it doesn't. And now you're debugging a codebase where any appears 847 times and every function accepts "whatever, man" as a valid argument.

Welcome to JavaScript Hell. TypeScript is the exit door—if you use it right.


What TypeScript IS

TypeScript is a static type system that catches bugs at compile time. It's JavaScript with guardrails that:

  • Tells you when you're accessing properties that don't exist
  • Autocompletes your objects because it knows what's in them
  • Documents your code through types (no more // user object, has stuff comments)
  • Refactors fearlessly because the compiler yells before users do

Think of it as spell-check for your logic. Red squiggles before you hit send.


What TypeScript is NOT

A runtime safety net — Types disappear after compilation. TypeScript won't save you from bad API responses at runtime.
A performance booster — It compiles to JavaScript. Same speed, same output.
An excuse to over-engineertype NestedGenericFactoryBuilderStrategy<T extends AbstractBase<K>, K> is not a flex.
Just "JavaScript with types" — Used properly, it changes how you design code, not just how you annotate it.


Everything You Need to Learn TypeScript

You don't need a 40-hour course. You need:

  1. Cheatsheets for the syntax stuff—types, interfaces, classes, control flow.
  2. The handbook to get oriented.
  3. A solid grasp of tsconfig.json before you touch anything. Half the time "TypeScript isn't working" actually means "I don't understand what strict: true does." Save yourself the existential crisis.

Practice (muscle memory)

Pick a small project—a CLI tool, a utility library, an API client—and type it strictly. You'll learn more from one strict: true project than ten tutorials.

The rest of this post? It's the thinking part—how to evolve from "TypeScript that compiles" to "TypeScript that protects."


The Refactoring Journey: From any to Type Safety

Let's fix a real function: fetching and displaying a user.

async function getUser(id: any): Promise<any> {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}

const user = await getUser(123);
console.log(user.emial); // typo? TypeScript: LGTM 👍
Enter fullscreen mode Exit fullscreen mode

Problems: Zero type safety. any in, any out.


25% — Basic Interface

interface User {
  id: number;
  name: string;
  email: string;
}

async function getUser(id: number): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  return res.json(); // ⚠️ lying to TypeScript
}

const user = await getUser(123);
console.log(user.emial); // ✅ Error: Property 'emial' does not exist
Enter fullscreen mode Exit fullscreen mode

What it does: Catches typos. Autocomplete works. Your IDE is useful now.

Problems: You're promising the API returns a User, but APIs lie. No runtime validation.


50% — Optional Properties & Null Handling

interface User {
  id: number;
  name: string;
  email?: string;        // might not exist
  avatar?: string | null; // might be null
}

async function getUser(id: number): Promise<User | null> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) return null;
  return res.json();
}

const user = await getUser(123);
if (user) {
  console.log(user.email?.toUpperCase()); // safe chaining
}
Enter fullscreen mode Exit fullscreen mode

What it does: Models reality—things can be missing or null. Forces you to handle edge cases.

Problems: Still trusting res.json() blindly. No validation that the response actually matches User.


75% — Discriminated Unions for API States

interface User {
  id: number;
  name: string;
  email?: string;
}

type ApiResult<T> =
  | { status: "loading" }
  | { status: "error"; message: string }
  | { status: "success"; data: T };

async function getUser(id: number): Promise<ApiResult<User>> {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) {
      return { status: "error", message: `HTTP ${res.status}` };
    }
    const data = await res.json();
    return { status: "success", data };
  } catch (e) {
    return { status: "error", message: "Network failed" };
  }
}

const result = await getUser(123);

switch (result.status) {
  case "loading":
    showSpinner();
    break;
  case "error":
    showError(result.message); // TS knows `message` exists here
    break;
  case "success":
    showUser(result.data);     // TS knows `data` is User here
    break;
}
Enter fullscreen mode Exit fullscreen mode

What it does: Models all possible states. TypeScript narrows types in each branch. Impossible to access data when status is "error".

Problems: Still no runtime guarantee that data matches User. If the API changes, you'll find out in production.


100% — Runtime Validation with Type Guards

interface User {
  id: number;
  name: string;
  email?: string;
}

// Type guard: validates at runtime, narrows at compile time
function isUser(obj: unknown): obj is User {
  return (
    typeof obj === "object" &&
    obj !== null &&
    "id" in obj &&
    typeof (obj as User).id === "number" &&
    "name" in obj &&
    typeof (obj as User).name === "string"
  );
}

type ApiResult<T> =
  | { status: "error"; message: string }
  | { status: "success"; data: T };

async function getUser(id: number): Promise<ApiResult<User>> {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) {
      return { status: "error", message: `HTTP ${res.status}` };
    }
    const json: unknown = await res.json(); // don't trust it

    if (!isUser(json)) {
      return { status: "error", message: "Invalid user data" };
    }

    return { status: "success", data: json }; // TS knows it's User
  } catch {
    return { status: "error", message: "Network failed" };
  }
}
Enter fullscreen mode Exit fullscreen mode

What it does:

  • Treats API response as unknown (honest)
  • Validates at runtime with a type guard
  • TypeScript narrows to User after validation passes
  • Catches API contract changes before they hit users

Production-ready. For complex schemas, use libraries like Zod or Valibot instead of hand-written guards.


The Current Landscape

Framework Conventional Choice Why
Next.js / React Zod, Valibot Functional style, tree-shakeable, no decorators needed
NestJS class-validator + class-transformer Decorator-based, integrates with NestJS pipes/DTOs natively

Interface vs Type vs Class: The Decision Tree

TL;DR:

  • Interface → Object shapes, public APIs, extending
  • Type → Unions, intersections, tuples, mapped types
  • Class → Runtime instances, private state, instanceof

Quick Reference

Question 🚩 Red Flag ✅ Benefit
"What type is this?" any everywhere Explicit interfaces
"Can this be null?" Unchecked .property access Optional chaining + null checks
"What can this function return?" Promise<any> Union types for all states
"Is this API response safe?" Casting as User Runtime validation + type guards
"What properties does this have?" console.log to find out Autocomplete knows
"Will this refactor break things?" "Deploy and pray" Compiler errors before merge

When NOT to Use (Full) TypeScript

🚫 Over-typing everything — Don't create interfaces for objects used once. Inline types work: function greet(user: { name: string }) {}
🚫 Complex generics for simple problems — If your type definition is longer than the function, reconsider.
🚫 Typing third-party API responses you don't control — Use runtime validation instead of lying with interfaces.
🚫 100% type coverage as a goal — Some any or unknown is fine at boundaries. Perfect is the enemy of shipped.


TypeScript Cursor Rules & AI Guidance

.cursorrules (or rules in cursor settings)

# TypeScript Rules

- Never use `any` — use `unknown` and narrow with type guards
- Treat all external data as `unknown`, validate with Zod
- Use discriminated unions for state (loading/error/success)
- Prefer Result pattern over thrown exceptions
- `strict: true` always
Enter fullscreen mode Exit fullscreen mode

Example AI Prompt

Write a TypeScript function to fetch users from `/api/users`.

Requirements:
- Use Zod for response validation
- Return a discriminated union: `{ status: "error"; message: string } | { status: "success"; data: User[] }`
- Handle network errors and validation failures separately
- Infer the User type from the Zod schema
Enter fullscreen mode Exit fullscreen mode

The Bottom Line

TypeScript isn't about adding colons and angle brackets to your code. It's about making illegal states unrepresentable.

Start here: Enable strict mode. Model your API responses honestly. Use unions for state. Validate at boundaries.


Stop writing types. Start designing with types.