TypeScript: The Practical Guide for JavaScript Developers (2026)
You know JavaScript. You've heard of TypeScript. Here's the practical guide to actually using it productively.
Why TypeScript in 2026?
JavaScript problems that TS solves:
→ "Cannot read property 'x' of undefined" — caught at compile time
→ "foo is not a function" — type checking catches wrong function calls
→ Refactoring nightmares — rename a property, find ALL usages instantly
→ Documentation lives with code — types ARE documentation
→ Better IDE experience — autocomplete, jump-to-definition, inline errors
The tradeoff:
+ More bugs caught before runtime
+ Safer refactoring
+ Self-documenting code
+ Better team collaboration (types as contracts)
- Initial learning curve
- Build step required (ts → js)
- Sometimes "fighting the compiler"
- Can hide behind types without understanding JS deeply
Verdict: For any non-trivial project → absolutely worth it.
Type Basics That Actually Matter
// === Primitive Types ===
let name: string = "Alice";
let age: number = 30;
let isActive: boolean = true;
let data: null = null;
let missing: undefined = undefined;
// TypeScript can infer most of these!
let name = "Alice"; // inferred as string
// Only annotate when inference isn't clear or for public APIs
// === Arrays ===
const numbers: number[] = [1, 2, 3];
const strings: Array<string> = ["a", "b", "c"];
const mixed: (string | number)[] = [1, "two", 3]; // Union type!
// Read-only arrays (prevent accidental mutation)
const config: readonly string[] = ["dev", "staging", "prod"];
config.push("test"); // ❌ Error! Cannot modify readonly array
// Tuples (fixed-length arrays with typed positions)
let point: [number, number] = [10, 20];
let httpStatus: [number, string] = [200, "OK"];
// Destructure them:
const [status, message] = httpStatus;
// === Objects ===
interface User {
id: string;
name: string;
email: string;
role?: string; // Optional property (?)
readonly createdAt: Date; // Read-only property
}
const user: User = {
id: "usr_123",
name: "Alice",
email: "alice@example.com",
createdAt: new Date(),
};
user.name = "Bob"; // ✅ OK
user.id = "new_id"; // ❌ Error! Readonly
// Type alias (alternative to interface)
type ID = string;
type Status = "active" | "inactive" | "suspended";
type UserWithStatus = User & { status: Status }; // Intersection
// === Functions ===
// Full annotation:
function greet(name: string): string {
return `Hello, ${name}`;
}
// Arrow function types:
const add = (a: number, b: number): number => a + b;
// Optional parameters:
function fetch(url: string, timeout?: number): Promise<Response> { ... }
// Default parameters:
function createElement(tag: string, className = ""): HTMLElement { ... }
// Rest parameters:
function sum(...numbers: number[]): number {
return numbers.reduce((total, n) => total + n, 0);
}
// Function overloads (same function, different parameter types):
function parse(input: string): Date; // Parse date string
function parse(input: number): Date; // Parse timestamp
function parse(input: string | number): Date {
return new Date(input);
}
// Now TS knows: parse("2024-01-15") returns Date, parse(1705276800) returns Date
// Return type: never (for functions that never return)
function fail(message: string): never {
throw new Error(message); // Always throws
}
function infiniteLoop(): never {
while (true) { /* ... */ } // Never exits
}
The Type System You'll Use Daily
// === Union Types (one of several types) ===
type Theme = "light" | "dark" | "system";
type HTTPMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
function setTheme(theme: Theme) { ... }
setTheme("light"); // ✅
setTheme("blue"); // ❌ Error! Not a valid theme
// Discriminated unions (pattern matching):
type Result<T> =
| { success: true; data: T }
| { success: false; error: { code: string; message: string } };
function handleResult(result: Result<User>) {
if (result.success) {
console.log(result.data.name); // TS knows data exists here
} else {
console.log(result.error.message); // TS knows error exists here
}
}
// === Generic Types (reusable components) ===
// Basic generic function:
function first<T>(items: T[]): T | undefined {
return items[0];
}
first([1, 2, 3]); // T = number, returns number | undefined
first(["a", "b"]); // T = string, returns string | undefined
// Generic interface:
interface ApiResponse<T> {
data: T;
status: number;
message: string;
timestamp: Date;
}
type UserResponse = ApiResponse<User>;
type PostsResponse = ApiResponse<Post[]>;
// Generic constraints:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
getProperty(user, "name"); // ✅ Returns string
getProperty(user, "age"); // ✅ If age exists on User
getProperty(user, "xyz"); // ❌ Error! "xyz" is not a key of User
// Utility types (built-in, use them constantly!):
// Partial<T> — all properties optional
function updateUser(id: string, updates: Partial<User>): User { ... }
// Required<T> — all properties required (opposite of Partial)
// Omit<T, K> — remove specific keys
type CreateUserInput = Omit<User, "id" | "createdAt">;
// Pick<T, K> — keep only specific keys
type PublicUser = Pick<User, "name" | "role">;
// Record<K, V> — dictionary/object type
const cache: Record<string, { data: unknown; expiry: number }> = {};
// ReturnType<T> — get return type of a function
type FetchResult = ReturnType<typeof fetchData>;
// Awaited<T> — unwrap Promise type
type ResolvedData = Awaited<Promise<User>>; // = User
// === Type Narrowing ===
function processValue(value: string | number | boolean) {
if (typeof value === "string") {
value.toUpperCase(); // TS knows it's string here
} else if (typeof value === "number") {
value.toFixed(2); // TS knows it's number here
} else {
value; // TS knows it's boolean here
}
}
// Check for null/undefined:
function printLength(text: string | null) {
if (text) {
text.length; // TS knows text is string (not null) here
}
// Optional chaining + nullish coalescing:
const len = text?.length ?? 0; // Safe access with fallback
}
// Type guard function:
function isUser(obj: unknown): obj is User {
return typeof obj === "object" && obj !== null && "id" in obj && "name" in obj;
}
if (isUser(data)) {
data.name; // TS knows it's User now!
}
Configuring tsconfig.json
{
"compilerOptions": {
/* Strict mode (enable these!) */
"strict": true,
"noUncheckedIndexedAccess": true, // array[i] could be undefined
"exactOptionalPropertyTypes": true,
/* Module resolution */
"module": "NodeNext", // Modern Node.js ESM/CJS support
"moduleResolution": "NodeNext",
"esModuleInterop": true, // Interop with CommonJS
"resolveJsonModule": true, // Import JSON files
/* Output */
"target": "ES2022", // Target JS version
"outDir": "./dist",
"rootDir": "./src",
"declaration": true, // Generate .d.ts files
"declarationMap": true, // Source map for .d.ts
"sourceMap": true, // Debug source maps
/* Code quality */
"noUnusedLocals": true, // Error on unused variables
"noUnusedParameters": true, // Error on unused params
"noImplicitReturns": true, // All paths must return value
"noFallthroughCasesInSwitch": true, // Prevent switch fallthrough bugs
"forceConsistentCasingInFileNames": true,
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@utils/*": ["src/utils/*"]
},
/* Skip type checking for node_modules */
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Common Patterns & Gotchas
// ❌ Using `any` defeats the purpose
const data: any = fetchData(); // No type safety at all
// ✅ Use `unknown` and narrow
const data: unknown = fetchData();
if (typeof data === "object" && data !== null && "items" in data) {
// Now safe to use
}
// ❌ `as` casts bypass type checking (use sparingly!)
const el = document.getElementById('myDiv') as HTMLDivElement;
// ✅ Better: type guard or proper typing
const el = document.getElementById<HTMLDivElement>('myDiv');
// ❌ Non-null assertion (!) can cause runtime crashes
const name = user!.name; // "Trust me, it's not null"
// ✅ Proper check:
const name = user?.name ?? "Anonymous";
// Typing API responses (very common task):
interface ApiError {
code: string;
message: string;
details?: Array<{ field: string; message: string }>;
}
async function apiCall<T>(url: string): Promise<ApiResponse<T>> {
const res = await fetch(url);
if (!res.ok) {
const error: ApiError = await res.json();
throw new Error(`${error.code}: ${error.message}`);
}
return res.json();
}
// Usage:
const users = await apiCall<User[]>('/api/users');
// users is properly typed as User[]!
// Typing event handlers:
function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
event.currentTarget.disabled = true; // Fully typed DOM element
console.log(`Clicked at (${event.clientX}, ${event.clientY})`);
}
// Typing useState/useRef:
const [count, setCount] = useState<number>(0);
const inputRef = useRef<HTMLInputElement>(null);
// Environment variables (with Vite/tsc):
declare namespace NodeJS {
interface ProcessEnv {
NODE_ENV: "development" | "production" | "test";
DATABASE_URL: string;
API_KEY?: string; // Optional
}
}
// Now process.env.DATABASE_URL is typed as string!
What's your biggest TypeScript question? What confused you when you started?
Follow @armorbreak for more practical developer guides.
Top comments (0)