Files
public-blog/content/posts/hello-world.mdx

526 lines
16 KiB
Plaintext

---
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<void>;
disconnect(): Promise<void>;
query<T>(sql: string, params?: unknown[]): Promise<T[]>;
}
type GetUserById = (id: number) => Promise<{ id: number; name: string } | null>;
async function createUserClient(dsn: string): Promise<DatabaseClient> {
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<T>(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<T>(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<T extends HasId>(items: T[], predicate: (item: T) => boolean): T | undefined {
return items.find(predicate);
}
// --- Generic type with default — useful for API client patterns ---
type ApiResponse<TData, TError = string> =
| { 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<Map extends Record<string, unknown>> {
private handlers = new Map<string, Array<(payload: unknown) => void>>();
on<K extends keyof Map>(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<K extends keyof Map>(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<EventMap>();
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<T> — make all properties optional
type ArticleInput = Partial<Article>;
const draft: ArticleInput = { title: "Hello World" }; // id, body, etc. are all optional
// Required<T> — make all properties required
function upsertArticle(input: Required<Article>): void {
console.log(`Upserting article: ${input.title}`);
}
// Pick<T, K> — select a subset of properties
type ArticleSummary = Pick<Article, "id" | "title" | "publishedAt">;
const summary: ArticleSummary = {
id: 1,
title: "TypeScript Basics",
publishedAt: new Date(),
};
// Omit<T, K> — exclude certain properties
type ArticlePreview = Omit<Article, "body">;
// Useful: body is too large to send in a list response
// Record<K, T> — map-like type
const statusMessages: Record<Status, string> = {
idle: "No request in progress",
loading: "Fetching data...",
success: "Data loaded",
error: "Request failed",
};
// Readonly<T> — make all properties immutable
interface Config {
host: string;
port: number;
}
const config: Readonly<Config> = { 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<keyof Article, "id" | "authorId" | "publishedAt">;
// NumericFields is "id" | "authorId" — only string keys that are numbers? No, extracted by assignment.
// Using Exclude to remove unwanted keys
type NonNullableFields = Exclude<keyof Article, "body">;
```
## 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<T> =
| { status: "pending"; timestamp: number }
| { status: "success"; data: T; timestamp: number }
| { status: "error"; error: { code: number; message: string }; timestamp: number };
function renderResult<T>(result: NetworkResult<T>): 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<T>): 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<Record<string, unknown>> {
const response = await fetch(url);
return response.json();
}
}
// --- Conditional types — types that depend on other types ---
type Flattened<T> = T extends Array<infer U> ? U : T;
type A = Flattened<number[]>; // number
type B = Flattened<string>; // string
type C = Flattened<boolean[]>; // boolean
// --- Conditional with `infer` in multiple positions ---
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type MaybePromise<T> = T extends Promise<any> | PromiseLike<any>
? Promise<Awaited<T>>
: Promise<T>;
// --- 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<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function safeParseJson(input: string): Result<unknown> {
try {
return { ok: true, value: JSON.parse(input) };
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error : new Error(String(error)),
};
}
}
function chainResults<T, U>(
result: Result<T>,
fn: (value: T) => Result<U>
): Result<U> {
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<string, unknown>).name as string,
}));
// --- Custom error classes with named constructors ---
class AppError extends Error {
constructor(
public readonly code: string,
message: string,
public readonly metadata?: Record<string, unknown>
) {
super(message);
this.name = "AppError";
}
}
class ValidationError extends AppError {
constructor(
field: string,
message: string,
metadata?: Record<string, unknown>
) {
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! 🚀