TypeScript: Stop Writing JavaScript With Extra Steps
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 stuffcomments) - 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‑engineer —
type 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.jsonbefore you touch anything. Half the time “TypeScript isn’t working” actually means “I don’t understand whatstrict: truedoes.” 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
Userafter 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‑shakable, 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
| Construct | Use When |
|---|---|
| Interface | Describing object shapes, public APIs, extending |
| Type | Unions, intersections, tuples, mapped types |
| Class | Runtime instances, private state, instanceof checks |
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 | 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
anyorunknownis 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:
- Enable strict mode.
- Model your API responses honestly.
- Use unions for state.
- Validate at boundaries.
Stop writing types. Start designing with types.