TypeScript: The Practical Guide for JavaScript Developers (2026)
TypeScript isn't just "JavaScript with types." It's a productivity multiplier that catches bugs before you run code.
Why TypeScript in 2026?
The honest comparison:
JavaScript:
→ Errors found at runtime (in production, on user's browser)
→ IDE autocomplete is guesswork
→ Refactoring is a game of "did I break something?"
→ Documentation lives separately from code
TypeScript:
→ Errors found at compile time (before you even save)
→ Perfect autocomplete (the compiler KNOWS your types)
→ Refactor with confidence (rename propagates everywhere)
→ Types ARE documentation that never goes stale
Trade-off: More upfront typing → way fewer debugging hours later
Setup
# Initialize TypeScript project
npm init -y
npm install --save-dev typescript @types/node tsx
npx tsc --init # Creates tsconfig.json
# package.json scripts:
{
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts", # Hot-reload dev server
"typecheck": "tsc --noEmit", # Type check only (no output)
"clean": "rm -rf dist"
}
}
Essential tsconfig.json
{
"compilerOptions": {
"target": "ES2022", /* Output JS version */
"module": "NodeNext", /* Modern ESM module resolution */
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true, /* Enable all strict checks! */
"noUncheckedIndexedAccess": true, /* Catch undefined array/object access */
"noUnusedLocals": true, /* Flag unused variables */
"noUnusedParameters": true, /* Flag unused parameters */
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true, /* Skip type checking .d.ts files (faster) */
"resolveJsonModule": true,
"esModuleInterop": true,
"declaration": true, /* Generate .d.ts for library consumers */
"declarationMap": true,
"sourceMap": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"] /* Path aliases */
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Type Basics That Actually Matter
Primitives and Basic Types
// Primitive types (lowercase!)
let name: string = "Alice";
let age: number = 30;
let isActive: boolean = true;
let data: null = null;
let nothing: undefined = undefined;
// bigint and symbol (less common but good to know)
const bigNum: bigint = 9007199254740991n;
const sym: symbol = Symbol("unique");
// Type inference — you don't always need to annotate!
let inferred = "hello"; // TS knows this is string
// Only annotate when:
// → Declaring without assignment (function params, class properties)
// → You want a MORE specific type than inference gives
// → Public API boundaries (function signatures)
// Arrays
const numbers: number[] = [1, 2, 3];
const strings: Array<string> = ["a", "b", "c"]; // Generic syntax (same thing)
const matrix: number[][] = [[1, 2], [3, 4]]; // Nested arrays
// Tuples (fixed-length, typed positions)
let point: [number, number] = [10, 20];
let httpResponse: [number, string] = [200, "OK"];
// Optional tuple elements:
let optionalTuple: [string, number?] = ["hello"]; // Second element optional
// Objects
const user: { name: string; age: number; email?: string } = {
name: "Alice",
age: 30,
// email is optional (marked with ?)
};
// Type alias (reusable object shape)
type User = {
id: string;
name: string;
email: string;
role: "admin" | "editor" | "viewer";
createdAt: Date;
preferences?: {
theme: "light" | "dark";
notifications: boolean;
};
};
Functions
// Function type annotations
function add(a: number, b: number): number {
return a + b;
}
// Return type can often be inferred
function greet(name: string) {
return `Hello, ${name}`; // TS infers return type as string
}
// Arrow functions
const multiply = (a: number, b: number): number => a * b;
// Default parameters
function createUser(name: string, role: string = "viewer"): User {
return { id: crypto.randomUUID(), name, role } as User;
}
// Rest parameters
function sum(...numbers: number[]): number {
return numbers.reduce((total, n) => total + n, 0);
}
// Destructured parameters with types
function printUser({ name, age }: { name: string; age: number }): void {
console.log(`${name} is ${age} years old`);
}
// Function overloads (different behavior based on input types)
function processInput(input: string): string[];
function processInput(input: string[]): string;
function processInput(input: string | string[]): string[] | string {
if (Array.isArray(input)) return input.join(", ");
return input.split(/\s+/);
}
// Callable signatures (for callback types)
type FilterFn<T> = (item: T, index: number) => boolean;
function filter<T>(items: T[], predicate: FilterFn<T>): T[] {
return items.filter(predicate);
}
Union Types & Narrowing
// Union: value can be one of several types
let id: string | number;
id = "abc123"; // OK
id = 456; // OK
id = true; // ERROR! boolean not allowed
// Literal unions (great for constants/flags)
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
type Status = "pending" | "active" | "archived";
// Discriminated unions (THE most powerful pattern!)
type Success<T> = { status: "success"; data: T };
type ErrorResult = { status: "error"; error: string; code: number };
type Loading = { status: "loading" };
type ApiResponse<T> = Success<T> | ErrorResult | Loading;
function handleResponse<T>(response: ApiResponse<T>) {
// TypeScript knows which properties exist in each branch!
switch (response.status) {
case "success":
console.log(response.data); // ✅ data exists here
break;
case "error":
console.log(response.error, response.code); // ✅ both exist
break;
case "loading":
console.log("Loading..."); // No extra properties
break;
}
}
// Type narrowing techniques
function narrowExample(value: string | number | null) {
// typeof check
if (typeof value === "string") {
value.toUpperCase(); // TS knows it's string here
}
// Truthiness check
if (value) {
value.toString(); // Not null or undefined here
}
// Array.isArray()
if (Array.isArray(value)) { /* ... */ }
// instanceof
if (value instanceof Date) { /* ... */ }
// In operator
if ("name" in value) { /* ... */ }
// Custom type guard function
}
Advanced Types You'll Use Daily
// Partial<T> — make all properties optional
function updateUser(id: string, updates: Partial<User>): User {
const existing = getUser(id);
return { ...existing, ...updates }; // Merge partial update
}
updateUser("usr_123", { email: "new@email.com" }); // Only email needed!
// Required<T> — make all properties required
// Omit<T, K> — remove specific keys
// Pick<T, K> — keep only specific keys
type UserPreview = Pick<User, "name" | "role">; // Just name + role
type CreateUserInput = Omit<User, "id" | "createdAt">; // Everything except id/createdAt
// Record<K, V> — dictionary/object type
const rolePermissions: Record<string, string[]> = {
admin: ["read", "write", "delete"],
editor: ["read", "write"],
viewer: ["read"],
};
// ReturnType<T> — extract return type of a function
type UserFromDb = typeof getUserFromDb; // Function type itself
type UserData = ReturnType<typeof getUserFromDb>; // What the function returns
// Parameters<T> — extract parameter types as tuple
type FetchParams = Parameters<typeof fetch>;
// [RequestInfo | URL, RequestInit?]
// Awaited<T> — unwrap Promise type
type ResolvedUser = Awaited<Promise<User>>; // = User (not Promise<User>)
// Template literal types
type EventName = `on${Capitalize<string>}`;
type CssProperty = `--${string}`;
// Mapped types
type Readonly<T> = { readonly [K in keyof T]: T[K] };
type Nullable<T> = { [K in keyof T]: T[K] | null };
// Conditional types
type IsString<T> = T extends string ? true : false;
type Result = IsString<"hello">; // true
type Result2 = IsString<number>; // false
// Utility: Extract response data type from API call
async function apiCall<T>(url: string): Promise<T> {
const res = await fetch(url);
return res.json();
}
type UsersResponse = Awaited<ReturnType<typeof apiCall<User[]>>>;
// = User[]
Generics: Reusable Type Logic
// Basic generic function
function identity<T>(arg: T): T {
return arg;
}
identity("hello"); // T = string
identity(42); // T = number
// Generic constraints
interface HasId {
id: string;
}
function findById<T extends HasId>(items: T[], id: string): T | undefined {
return items.find(item => item.id === id);
}
// Multiple type parameters
function merge<K, V>(keys: K[], values: V[]): Map<K, V> {
const map = new Map<K, V>();
keys.forEach((key, i) => map.set(key, values[i]));
return map;
}
// Generic with default type
interface ApiResponse<T = unknown> {
success: boolean;
data: T;
message?: string;
}
// Practical: Typed event emitter
class EventEmitter<Events extends Record<string, unknown[]>> {
#listeners = new Map<keyof Events, Function[]>();
on<E extends keyof Events>(event: E, listener: (...args: Events[E]) => void) {
if (!this.#listeners.has(event)) this.#listeners.set(event, []);
this.#listeners.get(event)!.push(listener);
}
emit<E extends keyof Events>(event: E, ...args: Events[E]) {
this.#listeners.get(event)?.forEach(fn => fn(...args));
}
}
// Usage with full type safety:
type AppEvents = {
"user:login": [user: User, timestamp: number];
"user:logout": [userId: string];
"error": [error: Error, context: string];
};
const bus = new EventEmitter<AppEvents>();
bus.on("user:login", (user, timestamp) => {
// user is typed as User, timestamp as number
});
bus.emit("error", new Error("test"), "login"); // Fully type-checked!
Error Handling
// Result pattern instead of try/catch (functional approach)
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
async function safeFetch<T>(url: string): Promise<Result<T>> {
try {
const res = await fetch(url);
if (!res.ok) return { success: false, error: new Error(`HTTP ${res.status}`) };
const data = await res.json() as T;
return { success: true, data };
} catch (e) {
return { success: false, error: e instanceof Error ? e : new Error(String(e)) };
}
}
// Usage: no try/catch needed at call site!
const result = await safeFetch<User>("/api/user/123");
if (result.success) {
console.log(result.data.name); // TS knows data exists
} else {
console.error(result.error.message); // TS knows error exists
}
// Never type (exhaustive checking)
type Role = "admin" | "editor" | "viewer";
function getPermission(role: Role): string[] {
switch (role) {
case "admin": return ["*"];
case "editor": return ["read", "write"];
case "viewer": return ["read"];
default:
// If you add a new Role later, TS will error here!
const _exhaustive: never = role;
throw new Error(`Unknown role: ${_exhaustive}`);
}
}
// assert functions (narrowing via assertion)
function assert(condition: unknown, msg?: string): asserts condition {
if (!condition) throw new Error(msg || "Assertion failed");
}
function processUser(user: User | null) {
assert(user, "User is required");
// After this line, TS knows user is NOT null
console.log(user.name);
}
Migration Strategy: JS → TS
Step 1: Add TypeScript to existing JS project
→ npm install --save-dev typescript @types/node
→ npx tsc --init (with allowJs: true, strict: false initially)
Step 2: Rename files .js → .ts one module at a time
→ Start with core utilities (no dependencies)
→ Work outward to modules with more dependencies
→ Don't do everything at once!
Step 3: Gradually enable strict options
→ Start: strict: false (permissive)
→ Then: noImplicitAny (catch untyped values)
→ Then: strictNullChecks (catch null/undefined issues)
→ Finally: strict: true (full safety)
Step 4: Add types to third-party libraries
→ npm install --save-dev @types/express @types/lodash etc.
→ Or write your own .d.ts declaration files
Key principle: Incremental adoption.
You don't need to convert everything at once.
Are you team JavaScript or team TypeScript? When do you choose each?
Follow @armorbreak for more practical developer guides.
Top comments (0)