TypeScript: The Practical Guide for JavaScript Developers (2026)
TypeScript isn't "just JavaScript with types" — it's a superpower for catching bugs before they ship. Here's what you actually need to know.
Why TypeScript Matters
// ❌ JavaScript: Bugs hide until runtime
function calculateDiscount(price, isPremium) {
return price * isPremium ? 0.8 : 1; // Operator precedence bug!
}
calculateDiscount(100, false); // Returns 0.8 instead of 100! 💥
// ✅ TypeScript: Catches it at compile time
// Error: can't multiply number by boolean implicitly
function calculateDiscount(price: number, isPremium: boolean): number {
return price * (isPremium ? 0.8 : 1); // Fixed!
}
Type System Essentials
// Primitive types
let name: string = "Alice";
let age: number = 30;
let isActive: boolean = true;
let data: null = null;
let nothing: undefined = undefined;
// Arrays
const numbers: number[] = [1, 2, 3];
const names: Array<string> = ["Alice", "Bob"];
const mixed: (string | number)[] = [1, "two", 3];
// Objects
interface User {
id: string;
name: string;
email?: string; // Optional property
readonly createdAt: Date; // Read-only
role: 'admin' | 'editor' | 'viewer'; // Union literal type
}
const user: User = {
id: crypto.randomUUID(),
name: "Alice",
createdAt: new Date(),
role: "admin"
};
// Functions with full typing
function fetchData<T>(url: string): Promise<T> { /* ... */ }
type FetchHandler<T> = (data: T, error?: Error) => void;
async function fetchWithHandler<T>(
url: string,
handler: FetchHandler<T>
): Promise<void> {
try {
const response = await fetch(url);
const data: T = await response.json();
handler(data);
} catch (err) {
handler({} as T, err as Error);
}
}
// Usage:
fetchWithHandler<User[]>('/api/users', (users, err) => {
if (err) console.error(err);
else users.forEach(u => console.log(u.name));
});
Advanced Types You'll Use Every Day
// Utility types (built-in):
interface Article {
title: string;
body: string;
author: User;
tags: string[];
publishedAt: Date;
}
// Partial — make all properties optional
function updateArticle(id: string, fields: Partial<Article>): Article { /* ... */ }
updateArticle("abc", { title: "New Title" }); // Only update title
// Required — make all properties required
type CompleteArticle = Required<Article>;
// Pick — select specific properties
type ArticlePreview = Pick<Article, 'title' | 'author' | 'publishedAt'>;
// Omit — exclude specific properties
type UnsavedArticle = Omit<Article, 'id' | 'createdAt'>;
// Record — key-value map
type RolePermissions = Record<string, string[]>;
const permissions: RolePermissions = {
admin: ['read', 'write', 'delete'],
editor: ['read', 'write'],
viewer: ['read']
};
// Custom utility types
type NonNullableFields<T> = { [K in keyof T]: NonNullable<T[K]> };
type Writable<T> = { -readonly [K in keyof T]: T[K] };
// Conditional types
type IsString<T> = T extends string ? true : false;
type Result = Isstring<string>; // true
// Mapped types
type ReadonlyDeep<T> = {
readonly [P in keyof T]: T[P] extends object ? ReadonlyDeep<T[P]> : T[P]
};
// Template literal types
type EventName = `on${Capitalize<string>}`;
type ClickEvent = 'onClick'; // Valid
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ApiRoute<Method extends HttpMethod, Path extends string> =
`${Method} ${Path}`;
Real-World 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<T>(state: RequestState<T>) {
switch (state.status) {
case 'idle': return 'Not started';
case 'loading': return 'Loading...';
case 'success': return `Got ${state.data}`; // TS knows data exists here!
case 'error': return `Error: ${state.error.message}`; // And error here!
}
}
// Pattern 2: Branded types (prevent mixing similar values)
type Brand<K, T> = K & { __brand: T };
type UserId = Brand<number, 'UserId'>;
type OrderId = Brand<number, 'OrderId'>;
function getUser(id: UserId) { /* ... */ }
function getOrder(id: OrderId) { /* ... */ */
const uid: UserId = 123 as UserId; // Must explicitly brand
const oid: OrderId = 456 as OrderId;
getUser(uid); // ✅
getUser(oid); // ❌ Error: OrderId not assignable to UserId!
// Pattern 3: Type-safe API client
class ApiClient {
async get<T>(path: string): Promise<T> {
const res = await fetch(path);
if (!res.ok) throw new ApiError(res.status, res.statusText);
return res.json() as Promise<T>;
}
async post<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new ApiError(res.status, res.statusText);
return res.json() as Promise<T>;
}
}
// Type-safe route definitions:
const api = new ApiClient();
const users = await api.get<User[]>('/api/users');
const user = await api.post<User>('/api/users', { name: 'Bob' });
// Pattern 4: Zod + TypeScript (runtime validation)
import { z } from 'zod';
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(2).max(100),
email: z.string().email(),
role: z.enum(['admin', 'editor', 'viewer'])
});
type UserFromSchema = z.infer<typeof UserSchema>; // Auto-derived type!
function validateUser(data: unknown): UserFromSchema {
return UserSchema.parse(data); // Throws on invalid data
}
// Pattern 5: Generic components (React example)
interface Props<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string;
emptyMessage?: string;
}
function List<T>({ items, renderItem, keyExtractor, emptyMessage = 'No items' }: Props<T>) {
if (items.length === 0) return <p>{emptyMessage}</p>;
return <ul>{items.map(item => <li key={keyExtractor(item)}>{renderItem(item)}</li>)}</ul>;
}
// Usage:
<List users={users} keyExtractor={u => u.id} renderItem={u => <span>{u.name}</span>} />
Migration Strategy
# Step 1: Add TypeScript to existing JS project
npm install --save-dev typescript @types/node @types/express
npx tsc --init
# tsconfig.json essentials:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"outDir": "./dist",
"rootDir": "./src",
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
// Migration path:
// 1. Rename .js → .ts (start with leaf files, no imports)
// 2. Add @ts-expect-error for things you'll fix later
// 3. Add types to function signatures first (biggest bang for buck)
// 4. Gradually enable stricter compiler options
// 5. Add JSDoc @types for .js files you haven't converted yet
// Quick wins — add types to these first:
// - Function parameters and return types
// - API request/response shapes
// - Database query results
// - Environment variables
What's your favorite TypeScript tip? What confused you most when learning it?
Follow @armorbreak for more practical developer guides.
Top comments (0)