Welcome back, future TypeScript master! In the previous chapters, we laid a solid foundation by understanding the core concepts of TypeScript, setting up our environment, and getting acquainted with basic types and variables. You’re already thinking in types, which is fantastic!

Now, it’s time to elevate our game. This chapter will dive into two fundamental building blocks of almost any application: functions and classes. We’ll explore how TypeScript empowers us to write more robust, predictable, and maintainable logic by adding types to our functions and embracing Object-Oriented Programming (OOP) principles with strongly typed classes. Get ready to bring clarity and safety to your code’s actions and structures!

By the end of this chapter, you’ll not only understand how to define and use typed functions and classes but also grasp the “why” behind these powerful features. You’ll be ready to structure your applications with confidence, knowing that TypeScript has your back, catching errors before your code even runs.


4.1. Typing Functions: Making Your Logic Predictable

Functions are the workhorses of programming. They take inputs, perform operations, and often return outputs. In plain JavaScript, it’s easy to pass the wrong type of data to a function or expect a different return type than what’s actually provided, leading to runtime errors. TypeScript swoops in to save the day by allowing us to explicitly define the types of arguments a function expects and the type of value it will return.

4.1.1. Basic Function Type Annotations

Let’s start with a super simple function. Imagine we want a function that adds two numbers.

Step 1: The JavaScript Way

First, let’s see how this would look in plain JavaScript. Create a new file named chapter4.ts in your src folder (or wherever you’re keeping your chapter files).

// src/chapter4.ts
function addNumbers(a, b) {
  return a + b;
}

console.log(addNumbers(5, 3)); // Works fine: 8
console.log(addNumbers("hello", "world")); // Uh oh... "helloworld" - not what we wanted!

Explanation: In JavaScript, addNumbers happily accepts any type for a and b. While 5 + 3 results in 8, "hello" + "world" results in "helloworld". This is JavaScript’s flexible (sometimes too flexible) nature. We intended to add numbers, but the code doesn’t enforce that intent.

Step 2: Adding Parameter Type Annotations

Now, let’s introduce TypeScript to enforce our intention. We’ll tell TypeScript that a and b must be numbers.

Modify src/chapter4.ts:

// src/chapter4.ts

function addNumbers(a: number, b: number) {
  return a + b;
}

console.log(addNumbers(5, 3)); // Still works: 8
// console.log(addNumbers("hello", "world")); // TypeScript will throw an error here!

Explanation: See those : number annotations after a and b? That’s TypeScript in action!

  • a: number: This tells TypeScript that the parameter a is expected to be of type number.
  • b: number: Similarly, b is also expected to be a number.

If you try to uncomment and compile console.log(addNumbers("hello", "world"));, your TypeScript compiler will immediately flag an error, something like: Argument of type '"hello"' is not assignable to parameter of type 'number'. This is fantastic because it catches the error before your code even runs!

Step 3: Adding Return Type Annotations

TypeScript can often infer the return type of a function (if a and b are numbers, a + b will likely be a number). However, explicitly stating the return type is a best practice, especially for clarity and complex functions. It also acts as an extra layer of safety.

Modify src/chapter4.ts again:

// src/chapter4.ts

function addNumbers(a: number, b: number): number {
  return a + b;
}

console.log(addNumbers(5, 3)); // Output: 8
// console.log(addNumbers("hello", "world")); // Still an error, good!

Explanation:

  • : number after the parameter list (a: number, b: number) but before the { body, specifies that this function must return a value of type number.
  • If you tried to return a string, like return "Result: " + (a + b);, TypeScript would again complain that a string is not assignable to a number return type. This helps prevent subtle bugs where a function might accidentally return the wrong type.

4.1.2. Optional and Default Parameters

Sometimes, a function parameter might not always be needed. TypeScript provides two ways to handle this: optional parameters and default parameters.

Step 1: Optional Parameters

To make a parameter optional, you simply add a ? after its name.

Modify src/chapter4.ts:

// src/chapter4.ts

// ... (previous code) ...

function greet(name: string, greeting?: string): string {
  if (greeting) {
    return `${greeting}, ${name}!`;
  }
  return `Hello, ${name}!`;
}

console.log(greet("Alice")); // Output: Hello, Alice!
console.log(greet("Bob", "Hi there")); // Output: Hi there, Bob!

Explanation:

  • greeting?: string: The ? makes greeting an optional parameter. Its type effectively becomes string | undefined.
  • You can call greet with one or two arguments. TypeScript will ensure that if greeting is provided, it’s a string.

Important Note on Optional Parameters: Optional parameters must always come after all required parameters in the function signature. TypeScript will give you an error if you try to put a required parameter after an optional one.

Step 2: Default Parameters

Default parameters allow you to provide a default value if an argument is not supplied, making the parameter optional and giving it a fallback.

Modify src/chapter4.ts:

// src/chapter4.ts

// ... (previous code) ...

function sendEmail(to: string, subject: string = "No Subject"): string {
  return `Sending email to ${to} with subject: "${subject}"`;
}

console.log(sendEmail("[email protected]")); // Output: Sending email to [email protected] with subject: "No Subject"
console.log(sendEmail("[email protected]", "Meeting Reminder")); // Output: Sending email to [email protected] with subject: "Meeting Reminder"

Explanation:

  • subject: string = "No Subject": If subject is not provided when calling sendEmail, it will automatically default to "No Subject".
  • Just like optional parameters, default-initialized parameters are also considered optional and must come after any required parameters.

4.1.3. Rest Parameters

When you don’t know how many arguments a function will receive, you can use rest parameters. This allows you to represent an indefinite number of arguments as an array.

Modify src/chapter4.ts:

// src/chapter4.ts

// ... (previous code) ...

function sumAll(...numbers: number[]): number {
  return numbers.reduce((total, num) => total + num, 0);
}

console.log(sumAll(1, 2, 3)); // Output: 6
console.log(sumAll(10, 20, 30, 40, 50)); // Output: 150
console.log(sumAll()); // Output: 0

Explanation:

  • ...numbers: number[]: The ... syntax denotes a rest parameter. It gathers all remaining arguments into a single array named numbers. We’ve typed this array as number[], meaning it will contain only numbers.
  • The reduce method is a standard JavaScript array method that applies a function against an accumulator and each element in the array (from left to right) to reduce it to a single value.

4.1.4. The void Return Type

What if a function doesn’t return anything? For functions that perform an action but don’t produce a value, TypeScript has the void type.

Modify src/chapter4.ts:

// src/chapter4.ts

// ... (previous code) ...

function logMessage(message: string): void {
  console.log(`LOG: ${message}`);
  // No return statement, or an empty return statement
}

logMessage("This is an important update!"); // Output: LOG: This is an important update!

Explanation:

  • : void indicates that the function logMessage does not return any meaningful value.
  • If you tried to assign the result of logMessage to a variable, TypeScript would correctly infer its type as void.

4.1.5. Arrow Functions with Types

Arrow functions are a concise way to write functions in JavaScript and TypeScript. Typing them is very similar to regular functions.

Modify src/chapter4.ts:

// src/chapter4.ts

// ... (previous code) ...

const multiply = (a: number, b: number): number => {
  return a * b;
};

console.log(multiply(4, 5)); // Output: 20

const greetUser = (name: string): void => console.log(`Welcome, ${name}!`);

greetUser("Charlie"); // Output: Welcome, Charlie!

Explanation:

  • The type annotations for parameters (a: number, b: number) and the return type (: number) are placed in the same positions as with traditional function declarations.
  • For single-expression arrow functions, the return type can often be inferred, but explicitly adding it is still a good practice.

4.2. Classes: Structuring Your Code with OOP

Classes are blueprints for creating objects. They allow us to bundle data (properties) and functions (methods) that operate on that data into a single, cohesive unit. TypeScript supercharges classes by allowing us to define the types of properties, method parameters, and return values, bringing strong typing to Object-Oriented Programming (OOP).

4.2.1. Basic Class Definition

Let’s define a simple Person class.

Step 1: The JavaScript Class

Here’s a basic Person class in JavaScript.

// src/chapter4.ts

// ... (previous function code) ...

class Person {
  name;
  age;

  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  greet() {
    console.log(`Hello, my name is ${this.name} and I am ${this.age} years old.`);
  }
}

const person1 = new Person("Alice", 30);
person1.greet(); // Output: Hello, my name is Alice and I am 30 years old.

const person2 = new Person("Bob", "twenty-five"); // JavaScript allows this!
person2.greet(); // Output: Hello, my name is Bob and I am twenty-five years old.

Explanation: In JavaScript, name and age are just properties. The constructor is a special method that gets called when you create a new instance of the class. Notice how person2 allows age to be a string, which might lead to unexpected behavior later if we try to perform arithmetic operations on it.

Step 2: Adding Type Annotations to Class Properties and Constructor

Now, let’s bring in TypeScript to ensure our Person class is type-safe.

Modify src/chapter4.ts:

// src/chapter4.ts

// ... (previous function code) ...

class Person {
  name: string; // Property type annotation
  age: number;  // Property type annotation

  constructor(name: string, age: number) { // Parameter type annotations
    this.name = name;
    this.age = age;
  }

  greet(): void { // Method return type annotation
    console.log(`Hello, my name is ${this.name} and I am ${this.age} years old.`);
  }
}

const person1 = new Person("Alice", 30);
person1.greet();

// const person2 = new Person("Bob", "twenty-five"); // TypeScript error!
// Argument of type '"twenty-five"' is not assignable to parameter of type 'number'.

Explanation:

  • name: string; and age: number;: We’ve explicitly declared the types of our class properties. This means name will always be a string and age will always be a number.
  • constructor(name: string, age: number): The constructor parameters are also typed, ensuring that when you create a Person object, you provide a string for name and a number for age.
  • greet(): void: Our method also gets a return type annotation, indicating it doesn’t return a value.

This small change makes our Person class much more reliable!

4.2.2. Access Modifiers: public, private, protected

TypeScript introduces access modifiers that control the visibility and accessibility of class members (properties and methods). These are public, private, and protected.

  • public (default): Members are accessible from anywhere. If you don’t specify an access modifier, it’s public by default.
  • private: Members are only accessible from within the class itself. They cannot be accessed from outside the class or by derived classes.
  • protected: Members are accessible from within the class itself and by derived classes (subclasses), but not from outside the class.

Let’s see them in action.

Modify src/chapter4.ts:

// src/chapter4.ts

// ... (previous code) ...

class Employee extends Person { // We'll talk about 'extends' soon!
  private employeeId: string;
  protected department: string; // Accessible in derived classes

  constructor(name: string, age: number, employeeId: string, department: string) {
    super(name, age); // Call the parent class's constructor
    this.employeeId = employeeId;
    this.department = department;
  }

  public getEmployeeInfo(): string { // 'public' is explicit, but default
    return `${this.name} (ID: ${this.employeeId}) works in ${this.department}.`;
  }

  private assignTasks(): void {
    console.log(`${this.name} is assigning tasks.`);
  }

  // A method that uses a private method
  public startWork(): void {
    this.assignTasks(); // Accessible within the class
    console.log(`${this.name} has started work.`);
  }
}

const employee1 = new Employee("David", 35, "EMP001", "Engineering");
console.log(employee1.getEmployeeInfo()); // Output: David (ID: EMP001) works in Engineering.
employee1.startWork(); // Output: David is assigning tasks. \n David has started work.

// console.log(employee1.employeeId); // Error: Property 'employeeId' is private and only accessible within class 'Employee'.
// console.log(employee1.department); // Error: Property 'department' is protected and only accessible within class 'Employee' and its subclasses.
// employee1.assignTasks(); // Error: Property 'assignTasks' is private and only accessible within class 'Employee'.

Explanation:

  • private employeeId: string;: This property can only be accessed from within the Employee class. Trying to access employee1.employeeId directly from outside will result in a compile-time error.
  • protected department: string;: This property can be accessed within Employee and any classes that extend Employee.
  • public getEmployeeInfo(): string: We explicitly used public here, but it’s the default.
  • private assignTasks(): void: This method can only be called from other methods inside the Employee class, like startWork.

Access modifiers are crucial for encapsulating data and behavior, which is a cornerstone of good OOP design.

4.2.3. The readonly Modifier

Sometimes you have properties that should be set only once (usually during initialization in the constructor) and never changed afterward. For this, TypeScript offers the readonly modifier.

Modify src/chapter4.ts:

// src/chapter4.ts

// ... (previous code) ...

class Product {
  readonly productId: string;
  public name: string;
  public price: number;

  constructor(productId: string, name: string, price: number) {
    this.productId = productId;
    this.name = name;
    this.price = price;
  }

  // This would cause an error!
  // public changeProductId(newId: string): void {
  //   this.productId = newId; // Error: Cannot assign to 'productId' because it is a read-only property.
  // }
}

const laptop = new Product("LAP001", "Super Laptop", 1200);
console.log(laptop.productId); // Output: LAP001

laptop.name = "Ultra Laptop"; // Allowed, 'name' is not readonly
// laptop.productId = "NEW_LAP001"; // Error: Cannot assign to 'productId' because it is a read-only property.

Explanation:

  • readonly productId: string;: Once productId is assigned a value in the constructor (or directly during declaration), it cannot be reassigned. This is excellent for identifiers or configuration values that should remain constant.

4.2.4. Constructor Parameter Properties (Shorthand)

TypeScript provides a neat shorthand for declaring class properties and initializing them from constructor parameters all in one go. If you use an access modifier (public, private, protected, or readonly) on a constructor parameter, TypeScript will automatically create a property with that name and assign the argument to it.

Let’s refactor our Person class using this shorthand.

Modify src/chapter4.ts:

// src/chapter4.ts

// ... (previous code) ...

// Refactored Person class using constructor parameter properties
class ModernPerson {
  // No need to declare 'name' and 'age' explicitly here!
  constructor(public name: string, private age: number) {
    // TypeScript automatically creates 'name' and 'age' properties
    // and assigns the constructor arguments to them.
  }

  public getDetails(): string {
    return `${this.name} is ${this.age} years old.`;
  }

  // We can still access 'age' internally
  private celebrateBirthday(): void {
    this.age++;
    console.log(`Happy birthday, ${this.name}! You are now ${this.age}.`);
  }

  public haveBirthday(): void {
    this.celebrateBirthday();
  }
}

const modernPerson = new ModernPerson("Eve", 28);
console.log(modernPerson.name); // Output: Eve (public)
// console.log(modernPerson.age); // Error: Property 'age' is private.
console.log(modernPerson.getDetails()); // Output: Eve is 28 years old.
modernPerson.haveBirthday(); // Output: Happy birthday, Eve! You are now 29.

Explanation:

  • constructor(public name: string, private age: number):
    • public name: string: This automatically declares a public name: string property on the ModernPerson class and assigns the name argument to this.name.
    • private age: number: Similarly, this declares a private age: number property and assigns the age argument.
  • This shorthand significantly reduces boilerplate code and is a very common and recommended practice in modern TypeScript (as of 2025).

4.2.5. Inheritance: extends and super

Object-Oriented Programming thrives on the concept of inheritance, where a new class can inherit properties and methods from an existing class. This promotes code reuse and establishes an “is-a” relationship (e.g., an Employee is a Person).

  • extends: Used to create a subclass (derived class) from a superclass (base class).
  • super(): Used in the constructor of a subclass to call the constructor of its superclass. It must be called before using this in the subclass’s constructor.

Let’s refine our Employee class to properly extend Person. (We already used extends and super briefly, but let’s focus on it now).

Modify src/chapter4.ts:

// src/chapter4.ts

// ... (previous code including ModernPerson) ...

// --- Let's define a BasePerson and TeamManager for a clearer inheritance example ---
class BasePerson {
  constructor(public name: string, protected age: number) { // age is now protected
    // Shorthand for property declaration and assignment
  }

  public sayHello(): void {
    console.log(`Hello, my name is ${this.name}.`);
  }

  protected getAge(): number { // Protected method, accessible by subclasses
    return this.age;
  }
}

class TeamManager extends BasePerson {
  private teamSize: number;

  constructor(name: string, age: number, teamSize: number) {
    super(name, age); // Call BasePerson's constructor
    this.teamSize = teamSize;
  }

  public manageTeam(): void {
    console.log(`${this.name} (age ${this.getAge()}) is managing a team of ${this.teamSize} people.`);
    // We can access 'this.age' via the protected 'getAge()' method from BasePerson
    // Or directly as 'this.age' because 'age' is protected.
  }

  public delegateTasks(): void {
    console.log(`${this.name} is delegating tasks to the team.`);
    // this.sayHello(); // Can call inherited public methods
  }
}

const manager1 = new TeamManager("Grace", 45, 10);
manager1.sayHello(); // Output: Hello, my name is Grace.
manager1.manageTeam(); // Output: Grace (age 45) is managing a team of 10 people.
manager1.delegateTasks(); // Output: Grace is delegating tasks to the team.

// console.log(manager1.age); // Error: Property 'age' is protected and only accessible within class 'BasePerson' and its subclasses.

Explanation:

  • class TeamManager extends BasePerson: TeamManager is a subclass of BasePerson. It inherits name, age, sayHello(), and getAge().
  • super(name, age);: In TeamManager’s constructor, we call super() to initialize the name and age properties in the BasePerson parent class. This is mandatory for subclasses.
  • this.getAge(): Inside TeamManager, we can access the protected getAge() method (and protected properties like age directly) inherited from BasePerson.

Inheritance is a powerful tool for building hierarchies of related objects.

4.2.6. Abstract Classes and Methods

Sometimes you want to define a base class that cannot be instantiated directly but serves as a template for other classes. This is where abstract classes come in. Abstract classes can contain abstract methods (methods without an implementation) that must be implemented by their concrete (non-abstract) subclasses.

  • abstract class: A class that cannot be instantiated directly.
  • abstract method: A method declared in an abstract class without an implementation. Subclasses must provide an implementation.

Modify src/chapter4.ts:

// src/chapter4.ts

// ... (previous code) ...

abstract class Shape {
  constructor(public color: string) {}

  abstract getArea(): number; // Abstract method - no implementation here
  abstract getPerimeter(): number; // Another abstract method

  public describe(): void {
    console.log(`This is a ${this.color} shape.`);
  }
}

// const myShape = new Shape("blue"); // Error: Cannot create an instance of an abstract class.

class Circle extends Shape {
  constructor(color: string, public radius: number) {
    super(color);
  }

  getArea(): number {
    return Math.PI * this.radius * this.radius;
  }

  getPerimeter(): number {
    return 2 * Math.PI * this.radius;
  }
}

class Rectangle extends Shape {
  constructor(color: string, public width: number, public height: number) {
    super(color);
  }

  getArea(): number {
    return this.width * this.height;
  }

  getPerimeter(): number {
    return 2 * (this.width + this.height);
  }
}

const myCircle = new Circle("red", 5);
myCircle.describe(); // Output: This is a red shape.
console.log(`Circle Area: ${myCircle.getArea()}`); // Output: Circle Area: 78.53...
console.log(`Circle Perimeter: ${myCircle.getPerimeter()}`); // Output: Circle Perimeter: 31.41...

const myRectangle = new Rectangle("green", 10, 4);
myRectangle.describe(); // Output: This is a green shape.
console.log(`Rectangle Area: ${myRectangle.getArea()}`); // Output: Rectangle Area: 40
console.log(`Rectangle Perimeter: ${myRectangle.getPerimeter()}`); // Output: Rectangle Perimeter: 28

Explanation:

  • abstract class Shape: We cannot create a new Shape(). It’s a template.
  • abstract getArea(): number;: Shape declares that any class extending it must have a getArea method that returns a number. It doesn’t provide an implementation itself.
  • Circle and Rectangle are concrete classes that extend Shape and must implement getArea() and getPerimeter(). If they didn’t, TypeScript would complain.

Abstract classes are great for defining common interfaces and partial implementations for related objects.

4.2.7. Interfaces with Classes (implements)

While abstract classes provide a base implementation and structure, interfaces define only the contract (what methods and properties a class must have) without any implementation details. A class can then implement one or more interfaces, guaranteeing it adheres to those contracts.

Let’s define an interface for Logger functionality.

Modify src/chapter4.ts:

// src/chapter4.ts

// ... (previous code) ...

interface Logger {
  log(message: string): void;
  error(message: string, error?: Error): void;
  warn(message: string): void;
}

class ConsoleLogger implements Logger {
  log(message: string): void {
    console.log(`[INFO] ${new Date().toISOString()}: ${message}`);
  }

  error(message: string, error?: Error): void {
    console.error(`[ERROR] ${new Date().toISOString()}: ${message}`, error);
  }

  warn(message: string): void {
    console.warn(`[WARN] ${new Date().toISOString()}: ${message}`);
  }
}

class FileLogger implements Logger {
  private fileName: string;

  constructor(fileName: string) {
    this.fileName = fileName;
  }

  log(message: string): void {
    // In a real app, this would write to a file
    console.log(`[FILE LOG - INFO] ${this.fileName}: ${message}`);
  }

  error(message: string, error?: Error): void {
    console.error(`[FILE LOG - ERROR] ${this.fileName}: ${message}`, error);
  }

  warn(message: string): void {
    console.warn(`[FILE LOG - WARN] ${this.fileName}: ${message}`);
  }
}

const myConsoleLogger: Logger = new ConsoleLogger();
myConsoleLogger.log("Application started successfully!");
myConsoleLogger.error("Failed to connect to database.", new Error("DB connection refused"));

const myFileLogger: Logger = new FileLogger("app.log");
myFileLogger.log("User 'admin' logged in.");

Explanation:

  • interface Logger: Defines a contract that any Logger class must adhere to. It specifies three methods: log, error, and warn, along with their parameter and return types.
  • class ConsoleLogger implements Logger: This class promises to implement all methods defined in the Logger interface. If it misses any, TypeScript will issue an error.
  • class FileLogger implements Logger: Another class implementing the same interface, but with a different internal implementation.
  • const myConsoleLogger: Logger = new ConsoleLogger();: We can type variables with the interface, allowing us to swap out different logger implementations as long as they adhere to the Logger contract. This is a core principle of polymorphism and dependency inversion.

Interfaces are incredibly powerful for achieving loose coupling and designing flexible architectures.

4.2.8. Static Properties and Methods

Sometimes, a property or method belongs to the class itself, not to any specific instance of the class. These are called static members. You access them directly on the class name, not on an object created from the class.

Modify src/chapter4.ts:

// src/chapter4.ts

// ... (previous code) ...

class Utility {
  static PI: number = 3.14159; // Static property

  static calculateCircleArea(radius: number): number { // Static method
    return this.PI * radius * radius;
  }

  static generateRandomId(): string {
    return Math.random().toString(36).substring(2, 9);
  }
}

console.log(Utility.PI); // Output: 3.14159
console.log(Utility.calculateCircleArea(10)); // Output: 314.159
console.log(Utility.generateRandomId()); // Output: (a random string)

// const util = new Utility(); // If Utility had a constructor, you could instantiate it,
//                              // but static members are still accessed on the class itself.
// util.calculateCircleArea(5); // Error: Property 'calculateCircleArea' does not exist on type 'Utility'.
//                              // You cannot call static methods on an instance.

Explanation:

  • static PI: number = 3.14159;: PI is a property of the Utility class itself, not individual Utility objects.
  • static calculateCircleArea(radius: number): number: This method is also called directly on the Utility class. Inside static methods, this refers to the class itself, so this.PI correctly accesses the static PI property.

Static members are perfect for utility functions, constants, or factory methods that don’t depend on the state of a specific object instance.


4.3. Mini-Challenge: Build a Simple Inventory System

Let’s put your new knowledge of functions and classes to the test!

Challenge: Create a small inventory system using TypeScript classes.

  1. Define a Product class:

    • It should have readonly id: string (generated automatically in the constructor).
    • name: string
    • price: number
    • quantity: number
    • A constructor that takes name, price, and quantity. The id should be generated using Utility.generateRandomId() (from our Utility class above, or you can embed the logic directly).
    • A public method displayProduct(): void that logs all product details to the console.
    • A public method updateQuantity(amount: number): void that adjusts the product’s quantity. Ensure amount is a number and the quantity doesn’t go below zero.
  2. Define an Inventory class:

    • It should have a private property products: Product[] (an array of Product objects).
    • A constructor that optionally takes an initial array of Products.
    • A public method addProduct(product: Product): void that adds a product to the inventory.
    • A public method removeProduct(productId: string): boolean that removes a product by its ID and returns true if successful, false otherwise.
    • A public method findProductById(productId: string): Product | undefined that finds a product by ID.
    • A public method listAllProducts(): void that iterates and calls displayProduct() for each product in the inventory.
    • A public method getTotalValue(): number that calculates the total value of all items in the inventory (price * quantity for each product).

Hint:

  • Remember to use super() if you decide to extend any base classes (though not strictly required for this challenge).
  • Think about proper type annotations for all properties, parameters, and return types.
  • For id generation, you can use Utility.generateRandomId() or Math.random().toString(36).substring(2, 9) for a simple unique ID.
  • Consider using array methods like push, filter, find, reduce.

What to observe/learn:

  • How to combine different type annotations for properties, parameters, and return types in classes.
  • The practical application of access modifiers (readonly, private).
  • Using static methods for utility functions.
  • Structuring a small application with multiple interacting classes.

4.4. Common Pitfalls & Troubleshooting

Even with TypeScript’s help, it’s easy to stumble. Here are a few common issues you might encounter with functions and classes, and how to fix them.

  1. Over-reliance on any:

    • Pitfall: Using any as a quick fix when TypeScript complains about types in functions or class properties. This defeats the purpose of TypeScript!
    • function processData(data: any): any { // Avoid this!
        // ... complex logic ...
        return data;
      }
      
      class Config {
        value: any; // Avoid this!
        constructor(v: any) { this.value = v; }
      }
      
    • Solution: Take the time to define specific types, even if they are complex (e.g., using union types string | number, interfaces, or generic types which we’ll cover later). If you truly don’t know the type, consider unknown which is safer than any.
    • Best Practice (2025): Always strive for explicit types. If any is absolutely necessary for interop with untyped JS, isolate its usage and document why.
  2. Incorrect this Context in Class Methods:

    • Pitfall: When passing a class method as a callback (e.g., to setTimeout, addEventListener), this can lose its context and become undefined or the global object in strict mode (which TypeScript uses by default).
    • class Counter {
        count: number = 0;
        constructor() {
          // This will cause 'this' to be undefined inside increment if 'increment' is a regular method
          setInterval(this.increment, 1000);
        }
        increment() {
          this.count++; // Error: Cannot read property 'count' of undefined
          console.log(this.count);
        }
      }
      // new Counter(); // If you uncomment this, you'll see the error in runtime
      
    • Solution 1 (Arrow Function as Class Property - Modern TS): Define the method as an arrow function property. Arrow functions lexically bind this, so it always refers to the class instance. This is the most common and recommended approach in modern TypeScript (2025).
      class CounterFixed1 {
        count: number = 0;
        // increment is now an arrow function property, binding 'this' automatically
        increment = () => {
          this.count++;
          console.log(this.count);
        }
        constructor() {
          setInterval(this.increment, 1000); // Works correctly now!
        }
      }
      // new CounterFixed1();
      
    • Solution 2 (Bind in Constructor): Explicitly bind this in the constructor.
      class CounterFixed2 {
        count: number = 0;
        constructor() {
          setInterval(this.increment.bind(this), 1000); // Bind 'this' explicitly
        }
        increment() {
          this.count++;
          console.log(this.count);
        }
      }
      // new CounterFixed2();
      
    • Solution 3 (Arrow Function Wrapper): Wrap the method call in an arrow function directly where it’s used as a callback.
      class CounterFixed3 {
          count: number = 0;
          constructor() {
              setInterval(() => {
                  this.increment(); // 'this' is correctly bound here by the outer arrow function
              }, 1000);
          }
          increment() {
              this.count++;
              console.log(this.count);
          }
      }
      // new CounterFixed3();
      
  3. Forgetting super() in Subclass Constructors:

    • Pitfall: If a subclass has a constructor, and its parent class also has a constructor, you must call super() in the subclass’s constructor before using this.
    • class Animal {
        name: string;
        constructor(name: string) { this.name = name; }
      }
      
      class Dog extends Animal {
        breed: string;
        constructor(name: string, breed: string) {
          // this.breed = breed; // Error: 'super' must be called before accessing 'this'
          // super(name); // Missing this call!
          this.breed = breed;
          // ... (rest of constructor logic)
        }
      }
      
    • Solution: Always call super() with the appropriate arguments for the parent constructor as the first statement in your subclass constructor.

4.5. Summary

Phew! You’ve just tackled a massive chunk of core TypeScript knowledge. Let’s quickly recap what we’ve learned:

  • Typed Functions: We learned to add type annotations to function parameters and return values, making our function logic predictable and catching errors early.
  • Optional and Default Parameters: You can make parameters optional using ? or provide default values, enhancing function flexibility.
  • Rest Parameters: The ... syntax allows functions to accept an indefinite number of arguments of a specific type.
  • void Return Type: Used for functions that don’t return any value.
  • Arrow Functions: How to apply type annotations to concise arrow function syntax.
  • Classes in TypeScript: We explored how to define classes with typed properties and methods, bringing strong typing to OOP.
  • Access Modifiers (public, private, protected): Essential for controlling the visibility and encapsulation of class members.
  • readonly Modifier: For properties that should only be initialized once and remain constant.
  • Constructor Parameter Properties: A powerful shorthand to declare and initialize class properties directly from the constructor.
  • Inheritance (extends, super): Building class hierarchies to promote code reuse and model “is-a” relationships.
  • Abstract Classes and Methods: Defining templates for classes that cannot be instantiated directly and enforcing method implementations in subclasses.
  • Interfaces with Classes (implements): Defining contracts that classes must adhere to, crucial for flexible and loosely coupled designs.
  • Static Members: Properties and methods that belong to the class itself, not to instances.
  • Common Pitfalls: We discussed avoiding any, handling this context, and remembering super() in constructors.

You’re now equipped to build structured, type-safe logic using functions and classes. This is a huge step towards writing robust, maintainable, and scalable applications!

What’s Next?

In Chapter 5, we’ll dive deeper into TypeScript’s type system by exploring Interfaces and Type Aliases. You’ll learn how to define custom types for complex objects, function signatures, and more, further solidifying your ability to model real-world data with precision. Get ready to unlock even more of TypeScript’s power!