---
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: /content/posts/hello-world/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! 🚀
