--- title: "Mastering TypeScript: From Basics to Advanced Patterns" date: "2025-01-15" excerpt: "A comprehensive guide to TypeScript's type system, from basic types to advanced patterns like conditional types, mapped types, and generics." author: Alex Chen tags: - typescript - programming - tutorial coverImage: /images/typescript-cover.jpg --- Welcome to this comprehensive TypeScript tutorial! Whether you are migrating a JavaScript project or starting fresh, this guide will walk you through TypeScript's type system from fundamentals to advanced patterns used in production codebases. ## 1. Understanding the Type System TypeScript's type system is a layer of **static analysis** that sits atop JavaScript. The compiler (`tsc`) performs type checking at build time and emits plain JavaScript that runs in any engine. ```ts // Primitive types — the building blocks let username: string = "alice"; let age: number = 28; let isActive: boolean = true; let tags: string[] = ["typescript", "frontend"]; let nullable: string | null = null; // Type inference — TypeScript can often guess types for you const port = 3000; // inferred as `number` const config = { host: "localhost", port: 8080 }; // inferred as `{ host: string; port: number }` // The `unknown` vs `any` distinction — critical for type safety let value: unknown = 42; // value.toPrecision(3); // ❌ Error: Property 'toPrecision' does not exist on 'unknown' if (typeof value === "number") { console.log(value.toPrecision(3)); // ✅ Safe after narrowing } ``` > **Key principle:** Prefer `unknown` over `any`. `any` disables all type checking, while `unknown` forces you to perform a runtime check before using the value. ## 2. Interfaces, Types, and Object Shapes TypeScript offers two primary ways to define object structures: `interface` and `type`. Understanding when to use each is essential for clean codebases. ```ts // --- Interface: best for object contracts (open extension via declaration merging) --- interface BaseConfig { debug: boolean; logLevel: "info" | "warn" | "error"; } // Declaration merging: multiple declarations are combined interface BaseConfig { retryCount: number; } // --- Type alias: better for unions, intersections, and mapped types --- type Status = "idle" | "loading" | "success" | "error"; // Intersection types — composing types type AdminConfig = BaseConfig & { permissions: string[]; }; // --- Practical example: a fully-typed service layer --- interface DatabaseClient { connect(): Promise; disconnect(): Promise; query(sql: string, params?: unknown[]): Promise; } type GetUserById = (id: number) => Promise<{ id: number; name: string } | null>; async function createUserClient(dsn: string): Promise { const connection = await fetch(`https://${dsn}/connect`); if (!connection.ok) throw new Error(`Connection failed: ${connection.status}`); return { async connect() { await connection.json(); }, async disconnect() { await connection.json(); }, async query(sql: string, params: unknown[] = []) { const response = await connection.clone().json(); return response.rows as T[]; }, }; } ``` ## 3. Generics — Writing Reusable, Type-Safe Code Generics let you write functions and classes that work with multiple types while preserving type information. They are the backbone of reusable TypeScript libraries. ```ts // --- Generic function: identity with preserved type --- function identity(value: T): T { return value; } const num = identity(42); // num has type `number` const str = identity("hi"); // str has type `string` // --- Constrained generics: limiting what types are accepted --- interface HasId { id: number; } function findFirst(items: T[], predicate: (item: T) => boolean): T | undefined { return items.find(predicate); } // --- Generic type with default — useful for API client patterns --- type ApiResponse = | { status: "success"; data: TData } | { status: "error"; error: TError }; type UserApiResponse = ApiResponse<{ id: number; name: string }>; // --- Real-world: a typed event emitter --- type EventMap = { click: { x: number; y: number }; keydown: { key: string; code: string }; load: { url: string }; }; class TypedEmitter> { private handlers = new Map void>>(); on(event: K, handler: (payload: Map[K]) => void): void { const key = event as string; if (!this.handlers.has(key)) { this.handlers.set(key, []); } this.handlers.get(key)!.push(handler as (payload: unknown) => void); } emit(event: K, payload: Map[K]): void { this.handlers.get(event as string)?.forEach((h) => h(payload)); } } // Usage — type safety for every event const emitter = new TypedEmitter(); emitter.on("click", (payload) => { // payload is { x: number; y: number } — auto-completion works! console.log(`Clicked at ${payload.x}, ${payload.y}`); }); emitter.on("keydown", (payload) => { // payload is { key: string; code: string } if (payload.key === "Escape") { console.log("User pressed Escape"); } }); ``` ## 4. Utility Types — Building Blocks for Complex Schemas TypeScript ships with a rich set of built-in utility types that transform existing types. Mastering these is essential for writing maintainable type definitions. ```ts interface Article { id: number; title: string; body: string; tags: string[]; publishedAt: Date; authorId: number; } // Partial — make all properties optional type ArticleInput = Partial
; const draft: ArticleInput = { title: "Hello World" }; // id, body, etc. are all optional // Required — make all properties required function upsertArticle(input: Required
): void { console.log(`Upserting article: ${input.title}`); } // Pick — select a subset of properties type ArticleSummary = Pick; const summary: ArticleSummary = { id: 1, title: "TypeScript Basics", publishedAt: new Date(), }; // Omit — exclude certain properties type ArticlePreview = Omit; // Useful: body is too large to send in a list response // Record — map-like type const statusMessages: Record = { idle: "No request in progress", loading: "Fetching data...", success: "Data loaded", error: "Request failed", }; // Readonly — make all properties immutable interface Config { host: string; port: number; } const config: Readonly = { host: "localhost", port: 3000 }; // config.port = 8080; // ❌ Error: Cannot assign to 'port' because it is a read-only property. // --- Advanced: extracting types from values --- const API_ENDPOINTS = { users: "/api/users", articles: "/api/articles", comments: "/api/comments", } as const; // Type is `"users" | "articles" | "comments"` type Endpoint = keyof typeof API_ENDPOINTS; // Type is `"/api/users" | "/api/articles" | "/api/comments"` type EndpointUrl = (typeof API_ENDPOINTS)[keyof typeof API_ENDPOINTS]; // --- Extract and Exclude type utilities --- type NumericFields = Extract; // NumericFields is "id" | "authorId" — only string keys that are numbers? No, extracted by assignment. // Using Exclude to remove unwanted keys type NonNullableFields = Exclude; ``` ## 5. Type Guards and Runtime Narrowing TypeScript narrows types at compile time based on runtime checks. This bridges the gap between static types and dynamic runtime behavior. ```ts // --- typeof type guards --- function formatValue(value: string | number): string { if (typeof value === "number") { return value.toFixed(2); } return value.toUpperCase(); } // --- in type guard --- interface Cat { meow(): void; furColor: string; } interface Dog { bark(): void; breed: string; } function describePet(pet: Cat | Dog): string { if ("meow" in pet) { // TypeScript knows pet is Cat here return `A ${pet.furColor} cat says meow!`; } // Here pet is Dog return `A ${pet.breed} dog says woof!`; } // --- Custom type guard — reusable predicate --- function isUser(obj: unknown): obj is { id: number; name: string; email: string } { return ( typeof obj === "object" && obj !== null && "id" in obj && "name" in obj && "email" in obj && typeof (obj as any).id === "number" && typeof (obj as any).name === "string" && typeof (obj as any).email === "string" ); } // --- Discriminated unions — the most powerful narrowing pattern --- type NetworkResult = | { status: "pending"; timestamp: number } | { status: "success"; data: T; timestamp: number } | { status: "error"; error: { code: number; message: string }; timestamp: number }; function renderResult(result: NetworkResult): string { switch (result.status) { case "pending": return `Loading since ${result.timestamp}`; case "success": return `Got ${result.data as unknown as string}`; case "error": return `Error ${result.error.code}: ${result.error.message}`; } } // Exhaustiveness checking — catch unhandled cases at compile time function assertNever(value: never): never { throw new Error(`Unexpected value: ${JSON.stringify(value)}`); } function exhaustiveCheck(result: NetworkResult): never { return assertNever(result); // ❌ Compile error if a case is missing! } ``` ## 6. Advanced Patterns: Decorators and Conditional Types These advanced patterns appear frequently in enterprise TypeScript codebases. Let's explore practical uses for both. ```ts // --- Decorators (experimental syntax, widely used in NestJS, Angular) --- function LogExecution(target: Object, propertyKey: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value; descriptor.value = async function (...args: unknown[]) { const start = performance.now(); console.log(`[LOG] ${propertyKey} called with args:`, args); try { const result = await originalMethod.apply(this, args); const elapsed = performance.now() - start; console.log(`[LOG] ${propertyKey} completed in ${elapsed.toFixed(2)}ms`); return result; } catch (error) { console.error(`[LOG] ${propertyKey} threw:`, error); throw error; } }; return descriptor; } class DataService { @LogExecution async fetchData(url: string): Promise> { const response = await fetch(url); return response.json(); } } // --- Conditional types — types that depend on other types --- type Flattened = T extends Array ? U : T; type A = Flattened; // number type B = Flattened; // string type C = Flattened; // boolean // --- Conditional with `infer` in multiple positions --- type UnwrapPromise = T extends Promise ? U : T; type MaybePromise = T extends Promise | PromiseLike ? Promise> : Promise; // --- Brand types for runtime type discrimination --- interface UserId { _tag: "UserId"; value: number; } interface ArticleId { _tag: "ArticleId"; value: number; } // These are NOT interchangeable despite both being `{ value: number }` const userId: UserId = { _tag: "UserId", value: 42 }; // const articleId: ArticleId = userId; // ❌ Error! // Factory functions with branded return types function createUserId(id: number): UserId { if (id <= 0) throw new Error("Invalid user ID"); return { _tag: "UserId", value: id }; } function createArticleId(id: number): ArticleId { if (id <= 0) throw new Error("Invalid article ID"); return { _tag: "ArticleId", value: id }; } ``` ## 7. Error Handling Best Practices A robust error handling strategy improves debugging and makes failures explicit in the type system. ```ts // --- Result type pattern — explicit error handling without exceptions --- type Result = | { ok: true; value: T } | { ok: false; error: E }; function safeParseJson(input: string): Result { try { return { ok: true, value: JSON.parse(input) }; } catch (error) { return { ok: false, error: error instanceof Error ? error : new Error(String(error)), }; } } function chainResults( result: Result, fn: (value: T) => Result ): Result { if (!result.ok) return { ok: false, error: result.error }; return fn(result.value); } // Usage — no try/catch needed at call sites const jsonResult = safeParseJson('{"name":"Alice"}'); const parsed = chainResults(jsonResult, (value) => ({ ok: true, value: (value as Record).name as string, })); // --- Custom error classes with named constructors --- class AppError extends Error { constructor( public readonly code: string, message: string, public readonly metadata?: Record ) { super(message); this.name = "AppError"; } } class ValidationError extends AppError { constructor( field: string, message: string, metadata?: Record ) { super("VALIDATION_ERROR", `${field}: ${message}`, { field, ...metadata }); this.name = "ValidationError"; } } class NotFoundError extends AppError { constructor(resource: string, id: string) { super("NOT_FOUND", `${resource} with id "${id}" not found`, { resource, id }); this.name = "NotFoundError"; } } // --- Centralized error handler --- function handleError(error: unknown): { status: number; message: string } { if (error instanceof AppError) { switch (error.code) { case "VALIDATION_ERROR": return { status: 400, message: error.message }; case "NOT_FOUND": return { status: 404, message: error.message }; default: return { status: 500, message: "An unexpected error occurred" }; } } if (error instanceof Error) { return { status: 500, message: error.message }; } return { status: 500, message: "Unknown error" }; } ``` ## 8. Configuration and Build Setup A proper TypeScript project needs thoughtful configuration. Here is a production-ready `tsconfig.json` pattern: ```json { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ES2022"], "outDir": "./dist", "rootDir": "./src", "strict": true, "noImplicitAny": true, "strictNullChecks": true, "strictFunctionTypes": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "declaration": true, "sourceMap": true }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist"] } ``` Key flags explained: 1. `strict: true` — enables all strict type checking options at once 2. `noImplicitAny: true` — disallows implicit `any` types 3. `strictNullChecks: true` — `null` and `undefined` are distinct types 4. `noUnusedLocals` / `noUnusedParameters` — catch dead code at compile time 5. `declaration: true` — emits `.d.ts` files for library consumers ## Next Steps You now have a solid foundation in TypeScript's type system. To continue your journey, here are some recommended directions: - **Deep dive into generics** — explore higher-kinded types and type-level programming - **Learn about module resolution** — understand `--moduleResolution` strategies (`NodeNext` vs `Node16` vs `Classic`) - **Explore testing with TypeScript** — type-safe assertions with libraries like `vitest` and `tsd` - **Study architectural patterns** — dependency injection, CQRS, and hexagonal architecture in TypeScript - **Contribute to type definitions** — help maintain `@types/*` packages on DefinitelyTyped TypeScript is a journey, not a destination. The type system rewards patience and pays dividends in developer confidence and reduced bug rates. Start small, enforce `strict` mode early, and let the compiler be your guide. Happy coding! 🚀