DEV Community

Alex Chen
Alex Chen

Posted on

TypeScript: The Practical Guide for JavaScript Developers (2026)

TypeScript: The Practical Guide for JavaScript Developers (2026)

TypeScript isn't just "JavaScript with types" — it's a completely different development experience. Here's how to actually use it effectively.

Why TypeScript Matters

// === The Problem TypeScript Solves ===
// JavaScript:
function getUser(id) {
  return db.find(id); // What does this return? Object? Array? Null?
}

const user = getUser(123);
console.log(user.name);     // Works... until it doesn't
console.log(user.emali);    // Typo! Silent undefined at runtime!
user.sendEmail();           // Does this method exist? Who knows!

// TypeScript catches ALL of these BEFORE you run the code:
interface User {
  id: number;
  name: string;
  email: string;
  sendEmail(): void;
}

function getUser(id: number): User | null {
  return db.find(id); // Return type is explicit
}

const user = getUser(123);
if (user) {
  console.log(user.name);   // ✅ Type-safe
  // console.log(user.emali); // ❌ Compile error: Property 'emali' does not exist
  user.sendEmail();          // ✅ We know this exists
}
Enter fullscreen mode Exit fullscreen mode

Type System Essentials

// === Basic Types ===
let name: string = "Alice";
let age: number = 30;
let isActive: boolean = true;
let data: any = "anything goes";       // Avoid when possible
let unknown: unknown = "safe any";     // Better than any — requires type check
let nothing: null = null;
let notDefined: undefined = undefined;

// === Arrays ===
let numbers: number[] = [1, 2, 3];
let strings: Array<string> = ["a", "b", "c"];
let mixed: (string | number)[] = [1, "two", 3, "four"]; // Union type array

// Read-only arrays (immutable):
const readonlyNumbers: readonly number[] = [1, 2, 3];
// readonlyNumbers.push(4); // ❌ Error: cannot modify readonly

// Tuples (fixed-length, typed arrays):
let coordinate: [number, number] = [10, 20];
coordinate[0]; // number
coordinate[1]; // number
// coordinate[2]; // ❌ Error: tuple only has 2 elements

// Named tuples (more readable):
type HTTPResponse = [number, string];
const response: HTTPResponse = [200, "OK"];
const [statusCode, statusText] = response; // Destructure with types!

// === Objects & Interfaces ===
// Interface (preferred for object shapes):
interface User {
  id: number;
  name: string;
  email: string;
  role?: string;              // Optional property
  readonly createdAt: Date;   // Cannot be modified after creation
  preferences: {             // Nested object type
    theme: 'light' | 'dark'; // Literal union type
    notifications: boolean;
  };
}

// Type alias (alternative to interface):
type UserID = number | string;        // Union type alias
type FetchUser = (id: UserID) => Promise<User>; // Function type alias

// Interface vs Type: When to use which?
// Interface → Object shapes, classes (extensible)
// Type → Unions, intersections, mapped types, primitives

// Extending interfaces:
interface AdminUser extends User {
  permissions: string[];
  manageUsers(): void;
}

// Intersection types (combine multiple types):
type Serializable = { toJSON(): string };
type WithID = { id: number };

type Entity = Serializable & WithID;
// Must have BOTH toJSON() AND id

// === Functions ===
// Full type annotation:
function add(a: number, b: number): number {
  return a + b;
}

// Return type inferred (usually let TS do this):
function multiply(a: number, b: number) {
  return a * b; // TS infers: returns number
}

// Optional parameters:
function greet(name: string, greeting?: string) {
  return `${greeting || 'Hello'}, ${name}!`;
}
greet("Alice");                    // "Hello, Alice!"
greet("Bob", "Welcome");          // "Welcome, Bob!"

// Default parameters:
function createURL(path: string, base: string = "https://api.example.com") {
  return `${base}${path}`;
}

// Rest parameters:
function sum(...numbers: number[]): number {
  return numbers.reduce((total, n) => total + n, 0);
}
sum(1, 2, 3, 4, 5); // 15

// Function overloading (different behavior based on input types):
function parseInput(input: string): string;
function parseInput(input: number): number;
function parseInput(input: string | number): string | number {
  if (typeof input === 'string') return input.toUpperCase();
  return input * 2;
}
parseInput("hello"); // "HELLO"
parseInput(42);      // 84

// Callback types:
function fetchData(
  url: string,
  onSuccess: (data: unknown) => void,
  onError?: (error: Error) => void
): void {
  fetch(url)
    .then(r => r.json())
    .then(onSuccess)
    .catch(err => onError?.(err));
}
Enter fullscreen mode Exit fullscreen mode

Generics: Reusable Components

// === The Problem: Losing Type Information ===
function getFirst(arr: string[]) { return arr[0]; } // Only works for strings
function getFirstNumber(arr: number[]) { return arr[0]; } // Only works for numbers
// Duplicating functions for each type = bad

// === The Solution: Generics ===
function getFirst<T>(arr: T[]): T | undefined {
  return arr[0];
  // T is a placeholder type that gets filled in when you call the function
}

getFirst([1, 2, 3]);      // T = number, returns number | undefined
getFirst(["a", "b"]);     // T = string, returns string | undefined
getFirst([{id: 1}]);      // T = {id: number}, returns {id: number} | undefined

// Multiple generic parameters:
function pair<K, V>(key: K, value: V): [K, V] {
  return [key, value];
}
pair("name", "Alice");         // [string, string]
pair(42, true);               // [number, boolean]

// Generic constraints (restrict what T can be):
interface HasId { id: number; }
function findById<T extends HasId>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id);
  // T must have an `id` property of type number
}

findById([{id: 1, name: "A"}, {id: 2, name: "B"}], 1);
// T = {id: number, name: string}, returns that type

// Generic interfaces (the foundation of libraries like Axios, Express):
interface ApiResponse<T> {
  success: boolean;
  data: T;
  error?: string;
  meta?: {
    page: number;
    total: number;
  };
}

async function fetchUser(id: number): Promise<ApiResponse<User>> {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
  // Response.data is automatically typed as User!
}

async function fetchPosts(): Promise<ApiResponse<Post[]>> {
  const res = await fetch('/api/posts');
  return res.json();
  // Response.data is automatically typed as Post[]!
}

// Generic classes:
class Repository<T extends { id: number }> {
  private items: T[] = [];

  add(item: T): void {
    this.items.push(item);
  }

  findById(id: number): T | undefined {
    return this.items.find(item => item.id === id);
  }

  findAll(): T[] {
    return [...this.items];
  }
}

const userRepo = new Repository<User>();
userRepo.add({ id: 1, name: "Alice", email: "alice@ex.com", createdAt: new Date() });
Enter fullscreen mode Exit fullscreen mode

Practical Patterns

// === Pattern 1: Discriminated Unions (Type-Safe State Machines) ===
type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

function handleState(state: RequestState<User>): string {
  switch (state.status) {
    case 'idle': return 'No request made';
    case 'loading': return 'Loading...';
    case 'success': return `Got user: ${state.data.name}`; // TS knows data exists here!
    case 'error': return `Error: ${state.error.message}`; // TS knows error exists here!
  }
  // No default needed — TS checks all cases exhaustively!
}

// === Pattern 2: Type Guards (Narrowing at Runtime) ===
// Custom type guard:
function isString(value: unknown): value is string {
  return typeof value === 'string';
}

function processValue(value: string | number) {
  if (isString(value)) {
    // value is narrowed to string here
    console.log(value.toUpperCase());
  } else {
    // value is narrowed to number here
    console.log(value.toFixed(2));
  }
}

// In operator type guard:
interface Dog { bark(): void; }
interface Cat { meow(): void; }
type Pet = Dog | Cat;

function makeSound(pet: Pet) {
  if ('bark' in pet) {
    pet.bark(); // TS knows it's a Dog
  } else {
    pet.meow(); // TS knows it's a Cat
  }
}

// === Pattern 3: Utility Types (Built-in Type Transformers) ===
interface Todo {
  id: number;
  title: string;
  description: string;
  completed: boolean;
  createdAt: Date;
  updatedAt: date;
}

// Partial<T> — All properties optional (great for updates!)
type TodoUpdate = Partial<Todo>;
// Same as Todo but every field has ?

// Required<T> — All properties required
type CompleteTodo = Required<Pick<Todo, 'title' | 'completed'>>;

// Pick<T, K> — Only select specific fields
type TodoPreview = Pick<Todo, 'id' | 'title' | 'completed'>;

// Omit<T, K> — Everything EXCEPT specific fields
type NewTodo = Omit<Todo, 'id' | 'createdAt' | 'updatedAt'>;

// Record<K, V> — Dictionary/Map type
type RolePermissions = Record<string, string[]>;
// e.g., { admin: ['read', 'write', 'delete'], user: ['read'] }

// ReturnType<T> — Get return type of function
type HandlerReturn = ReturnType<typeof fetchUser>; // Promise<ApiResponse<User>>

// Parameters<T> — Get parameter types of function
type FetchParams = Parameters<typeof fetchUser>; // [number]

// === Pattern 4: const Assertions (Most Specific Types) ===
// Without const assertion:
const config = { host: "localhost", port: 3000 };
// Type: { host: string; port: number }

// With const assertion:
const CONFIG = { host: "localhost", port: 3000 } as const;
// Type: { readonly host: "localhost"; readonly port: 3000 }
// Values are literal types, properties are readonly!

// Great for configuration objects and route definitions:

const ROUTES = {
  home: '/',
  users: '/users',
  userProfile: '/users/:id' as const,
} as const;

type RouteKey = keyof typeof ROUTES; // 'home' | 'users' | 'userProfile'
type RoutePath = (typeof ROUTES)[RouteKey]; // '/' | '/users' | '/users/:id'

// === Pattern 5: satisfies Operator (Validate without widening) ===
// "as const" makes everything readonly. "satisfies" validates without changing type.
const COLORS = {
  primary: '#3b82f6',
  secondary: '#8b5cf6',
  danger: '#ef4444',
} satisfies Record<string, string>; // Validates shape but keeps literal types
Enter fullscreen mode Exit fullscreen mode

What TypeScript feature changed your workflow the most? What's still confusing about generics?

Follow @armorbreak for more practical developer guides.

Top comments (0)