TypeScript: Stop Writing JavaScript With Extra Steps

Published: (February 16, 2026 at 04:02 PM EST)
8 min read
Source: Dev.to

Source: Dev.to

Introduction

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, 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:

  • Cheatsheets for the syntax stuff—types, interfaces, classes, control flow.
  • The handbook to get oriented.
  • 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

0 % — The Problem

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

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

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 {
  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

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 {
  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
}

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 =
  | { status: "loading" }
  | { status: "error"; message: string }
  | { status: "success"; data: T };

async function getUser(id: number): Promise> {
  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 {
    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;
}

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 =
  | { status: "error"; message: string }
  | { status: "success"; data: T };

async function getUser(id: number): Promise> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) {
    return { status: "error", message: `HTTP ${res.status}` };
  }
  const data = await res.json();
  if (isUser(data)) {
    return { status: "success", data };
  }
  return { status: "error", message: "Invalid payload" };
}

What it does: Guarantees at runtime that the payload conforms to User. The type guard narrows the type for the compiler, so you get both safety and autocompletion.

Result: You’ve moved from “any” to a fully typed, validated flow—your code now fails fast in development and protects you in production.

Code Example

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" };
}

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

FrameworkConventional ChoiceWhy
Next.js / ReactZod, ValibotFunctional style, tree‑shakable, no decorators needed
NestJSclass-validator + class-transformerDecorator‑based, integrates with NestJS pipes/DTOs natively

Interface vs. Type vs. Class: The Decision Tree

Decision Tree

TL;DR

ConstructUse When
InterfaceDescribing object shapes, public APIs, extending
TypeUnions, intersections, tuples, mapped types
ClassRuntime instances, private state, instanceof checks

Quick Reference

Question🚩 Red Flag✅ Benefit
“What type is this?”any everywhereExplicit interfaces
“Can this be null?”Unchecked .property accessOptional chaining + null checks
“What can this function return?”PromiseUnion types for all states
“Is this API response safe?”Casting as UserRuntime validation + type guards
“What properties does this have?”console.log to find outAutocomplete 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

Create a .cursorrules file (or add rules in your 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

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

The Bottom Line

TypeScript isn’t about sprinkling colons and angle brackets everywhere. It’s about making illegal states unrepresentable.

Start here:

  1. Enable strict mode.
  2. Model your API responses honestly.
  3. Use unions for state.
  4. Validate at boundaries.

Stop writing types. Start designing with types.

0 views
Back to Blog

Related posts

Read more »