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
}
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));
}
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() });
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
What TypeScript feature changed your workflow the most? What's still confusing about generics?
Follow @armorbreak for more practical developer guides.
Top comments (0)