Five intermediate lessons covering Classes, Generics, Enums, Type Narrowing, and Modules — the concepts that separate beginners from TypeScript developers.
Blueprints for creating objects — TypeScript supercharges OOP with access modifiers and strict typing.
Classes are blueprints for creating objects. TypeScript adds access modifiers and strong typing on top of JavaScript classes, making Object-Oriented Programming safer and more expressive.
class Person { name: string; age: number; constructor(name: string, age: number) { this.name = name; this.age = age; } greet(): string { return `Hi, I'm ${this.name} and I'm ${this.age}.`; } } const alice = new Person("Alice", 25); console.log(alice.greet()); // Hi, I'm Alice and I'm 25.
Accessible from anywhere (default)
Only accessible inside the class
Class and its subclasses only
class BankAccount { public owner: string; private balance: number; constructor(owner: string, initialBalance: number) { this.owner = owner; this.balance = initialBalance; } deposit(amount: number): void { this.balance += amount; } getBalance(): number { return this.balance; // ✅ private access inside class } } const acc = new BankAccount("Alice", 1000); acc.deposit(500); console.log(acc.getBalance()); // 1500 // acc.balance ❌ Error — private!
Verbose way
class Dog { name: string; breed: string; constructor( name: string, breed: string ) { this.name = name; this.breed = breed; } }
✨ Shorthand way
class Dog { constructor( public name: string, public breed: string ) {} } // Same result — much cleaner!
class Animal { constructor(public name: string) {} speak(): string { return `${this.name} makes a sound.`; } } class Dog extends Animal { constructor(name: string, public breed: string) { super(name); // call parent constructor } speak(): string { return `${this.name} barks!`; } } const dog = new Dog("Rex", "Labrador"); console.log(dog.speak()); // Rex barks!
abstract class Shape { abstract getArea(): number; // must be implemented by subclass describe(): string { return `Area: ${this.getArea()}`; } } class Circle extends Shape { constructor(private radius: number) { super(); } getArea(): number { return Math.PI * this.radius ** 2; } } const c = new Circle(5); console.log(c.describe()); // Area: 78.53...
Write once, work with any type — the secret to truly reusable TypeScript code.
Generics let you write reusable, type-safe code that works with different data types. Instead of writing the same function multiple times for each type, you write it once using <T>.
❌ Without generics
function getFirstNum( arr: number[] ): number { return arr[0]; } function getFirstStr( arr: string[] ): string { return arr[0]; } // Duplicate code!
✅ With generics
function getFirst<T>( arr: T[] ): T { return arr[0]; } getFirst([1, 2, 3]); // number getFirst(["a","b"]); // string getFirst([true]); // boolean
interface ApiResponse<T> { data: T; success: boolean; message: string; } interface User { name: string; email: string; } // Response containing a User const userRes: ApiResponse<User> = { data: { name: "Alice", email: "[email protected]" }, success: true, message: "User found" }; // Response containing a number const countRes: ApiResponse<number> = { data: 42, success: true, message: "Count retrieved" };
// T must have a .length property function logLength<T extends { length: number }>(value: T): void { console.log(value.length); } logLength("hello"); // 5 ✅ strings have .length logLength([1, 2, 3]); // 3 ✅ arrays have .length // logLength(42); ❌ numbers don't have .length // Multiple type parameters function merge<T, U>(obj1: T, obj2: U): T & U { return { ...obj1, ...obj2 } as T & U; } const merged = merge({ name: "Alice" }, { age: 25 }); // merged: { name: string, age: number }
Named constants that make your code readable — goodbye magic strings and numbers.
Enums (enumerations) let you define a set of named constants. They replace "magic" values with meaningful names, making code self-documenting.
Default: auto-numbered from 0. Or set a custom start value.
Explicit string values. More debuggable and preferred in modern TS.
Inlined at compile time. Zero runtime cost — best performance.
// Numeric enum — auto-numbered enum Direction { Up, // 0 Down, // 1 Left, // 2 Right // 3 } let move: Direction = Direction.Up; console.log(move); // 0 // String enum — preferred! enum Status { Pending = "PENDING", Active = "ACTIVE", Inactive = "INACTIVE" } enum UserRole { Admin = "admin", User = "user", Guest = "guest" } function checkRole(role: UserRole): void { if (role === UserRole.Admin) { console.log("Welcome, admin!"); } } // const enum — compiled away for performance const enum Color { Red = "RED", Green = "GREEN", Blue = "BLUE" } let fav: Color = Color.Blue; // Compiles to: let fav = "BLUE" — no enum object!
Teach TypeScript to figure out the exact type from a broad one — essential for union types.
Type narrowing is how TypeScript figures out a more specific type from a broader one. This is essential when working with union types or values that could be different things at runtime.
// typeof guard — for primitives function formatValue(value: string | number): string { if (typeof value === "string") { // TypeScript knows: value is string here return value.toUpperCase(); } else { // TypeScript knows: value is number here return value.toFixed(2); } } console.log(formatValue("hello")); // "HELLO" console.log(formatValue(3.14159)); // "3.14"
// instanceof guard — for classes class Cat { meow() { return "Meow!"; } } class Dog { bark() { return "Woof!"; } } function makeSound(animal: Cat | Dog): string { if (animal instanceof Cat) { return animal.meow(); // TS knows: Cat } return animal.bark(); // TS knows: Dog } makeSound(new Cat()); // "Meow!" makeSound(new Dog()); // "Woof!"
// "in" guard — check if property exists interface Admin { name: string; adminLevel: number; } interface RegularUser { name: string; email: string; } function describeUser(user: Admin | RegularUser): string { if ("adminLevel" in user) { return `Admin level: ${user.adminLevel}`; // Admin } return `Email: ${user.email}`; // RegularUser } // Type assertion with "as" — use carefully! const input = document.getElementById("name") as HTMLInputElement; console.log(input.value); // TS now knows .value exists
Split your code across files — the modern way to organize growing TypeScript projects.
As your projects grow, you'll split code into multiple files (modules). TypeScript fully supports ES Modules — the modern standard for sharing code between files.
📤 Exporting
// math.ts export function add( a: number, b: number ): number { return a + b; } export const PI: number = 3.14159; // default export (one per file) export default function multiply( a: number, b: number ): number { return a * b; }
📥 Importing
// app.ts import { add, PI } from "./math"; import multiply from "./math"; import * as Math from "./math"; console.log(add(2, 3)); // 5 console.log(PI); // 3.14159 console.log(multiply(3,4)); // 12 console.log(Math.add(1,2)); // 3
// types.ts — share your types! export interface User { id: number; name: string; email: string; } export type Status = "active" | "inactive" | "banned"; // userService.ts — import with "import type" (preferred) import type { User, Status } from "./types"; function getUser(id: number): User { return { id, name: "Alice", email: "[email protected]" }; }
my-project/ ├── src/ │ ├── types/ │ │ └── index.ts ← all shared types & interfaces │ ├── utils/ │ │ └── helpers.ts ← utility functions │ ├── services/ │ │ └── userService.ts ← business logic │ └── index.ts ← main entry point ├── tsconfig.json ← TypeScript config └── package.json
💡 Pro tip: Run tsc --init to auto-generate a tsconfig.json for your project with all options and their documentation commented in.