Skip to content

TS4: classes

chapter 11: Classes

constructor functions

  • it is bad idea to use constructor functions in TS, use classes instead
  • usually TS compiler assign type any to objects generated by constructor functions which creates a gap in the type system.
  • you can use type casting to cast these objects to the correct type, but it is still a bad idea to use constructor functions in TS.
  • you can still give the same name to variable and type, but it is not recommended, this is possible since TS compiler tracks types and variables separately.
type Person = {
    name: string;
    age: number;
};

const Person: Person = {
    name: "John",
    age: 30,
};

classes

  • TS classes can look different from the standard JS classes, but the the compiler will generate fully standard JS classes that depends on JS constructor functions and prototype inheritance at runtime.
  • TS access modifiers keywords:
    • public: property accessible from anywhere
    • private: property accessible only from inside the class
    • protected: property accessible from inside the class and from its subclasses
  • access modifiers work only during development, at runtime the properties still on objects and can be accessible
  • compiler option strictPropertyInitialization tells compiler to raise an error if a class defines a property without initializing it and assigning it a value. the initialization may happen in the constructor or in the class declaration.

private properties

  • to ensure that a property is actually private, you can use:
    • the private keyword and JS getters/setters
    • JS setters/getters and JS private properties syntax(aka. property name starts with #).
class PersonT {
    private _name: string; // use of private keyword
    #age: number; // use of JS private properties syntax
    get name(): string {
        // getter
        return this._name;
    }
    set name(value: string) {
        // setter
        this._name = value;
    }
    get age(): number {
        // getter
        return this.#age;
    }
    set age(value: number) {
        // setter
        this.#age = value;
    }
}
  • although using JS syntax for private properties may guarantee that the property is private during runtime, it is not recommended. stick to use the private keyword.

readonly properties

  • readonly properties are properties that can only be set once (during instantiation, aka. in the constructor).
// readonly properties
class PersonT2 {
    readonly id: number;
    name: string;
    constructor(id: number, name: string) {
        this.id = id;
        this.name = name;
    }
}

const person2 = new PersonT2(1, "John");
person2.name = "Mike"; // Fine
person2.id = 2; // Error: Cannot assign to 'id' because it is a read-only property.
  • The readonly keyword must come after the access control keyword if one has been used. eg. public readonly | private readonly.

Concise constructor syntax

  • TypeScript supports a more concise syntax for constructors that avoids the define and assign
// define and assign pattern
class PersonT3 {
    private readonly id: string;
    public name: string;
    public age: number;

    constructor(id: string, name: string, age: number) {
        this.id = id;
        this.name = name;
        this.age = age;
    }
}

// Concise constructor syntax
class PersonT4 {
    constructor(private readonly id: string, public name: string, public age: number) {
        // no assign statements needed, assigning happens automatically
    }
}

inheritance

  • use extends keyword to inherit from another class, then call the super keyword to call the constructor of the parent class.
  • calling to the super constructor is optional, but it is recommended to call it.
  • calling to the super constructor must be the first statement in the constructor.

abstract classes

  • Abstract classes cannot be instantiated directly and are used to describe common functionality that must be implemented by subclasses
  • any abstract methods must be implemented in the subclasses
abstract class PersonT3 {
    constructor(private readonly id: string, public name: string, public age: number) {}
    abstract sayHello(): void;
}

class EmployeeT3 extends PersonT3 {
    constructor(id: string, name: string, age: number) {
        super(id, name, age);
    }
    sayHello() {
        console.log(`I'm an employee`, `Hello, my name is ${this.name} and I am ${this.age} years old`);
    }
}

class CustomerT3 extends PersonT3 {
    constructor(id: string, name: string, age: number) {
        super(id, name, age);
    }
    sayHello() {
        console.log(`I'm a customer`, `Hello, my name is ${this.name} and I am ${this.age} years old`);
    }
}
  • Objects instantiated from classes derived from an abstract class can be used through the abstract class type. eg. objects of type CustomerT3 and EmployeeT3 can be stored in an array of PersonT3
  • type guarding against classes can work properly using instanceof, but it is important to now that this returns true for both parent and child classes.
const e = new EmployeeT3("1", "John", 30);
e instanceof PersonT3; // true
e instanceof EmployeeT3; // true
e instanceof CustomerT3; // false

Interfaces

  • Interfaces are used to describe the shape of an object, which a class that implements the interface must conform to.
  • interfaces and types are very similar, but interface word comes from OOP world, it is recommended to use type unless the type you are defining is shaping a class then use interface and implement keywords.
  • A class can implement more than one interface, meaning it must define the methods and properties defined by all of them.
  • Interfaces can be defined in multiple interface declarations, which are merged by the compiler to form a single interface.
  • interfaces define public properties and methods only, private properties and methods can not appear in interfaces.
  • a single class can implement multiple interfaces, but it can only inherit one class.
interface PersonT4 {
    // id: string; // interfaces define public properties and methods only, id is private and cannot be in the interface
    name: string;
    age: number;
}

interface PersonT4 {
    // compiler automatically merges previous definition with this one resulting in a single definition of PersonT4
    // that contains all the properties and methods of both interfaces
    sayHello(): void;
}

interface CustomerDetailedT4 {
    getPastOrderIds(): string[];
    computeTotalOrderValue(): number;
}

class EmployeeT4 implements PersonT4 {
    constructor(private readonly id: string, public name: string, public age: number) {}

    sayHello() {
        console.log(`I'm an employee`, `Hello, my name is ${this.name} and I am ${this.age} years old`);
    }
}

// CustomerT4 implements PersonT4 and CustomerDetailedT4
class CustomerT4 implements PersonT4, CustomerDetailedT4 {
    constructor(private readonly id: string, public name: string, public age: number) {}

    sayHello() {
        console.log(`I'm a customer`, `Hello, my name is ${this.name} and I am ${this.age} years old`);
    }

    getPastOrderIds(): string[] {
        return ["1", "2"];
    }

    computeTotalOrderValue(): number {
        return 100;
    }
}
  • Interfaces can be extended, and the result is a new interface that contains all the properties and methods of the original interfaces.
interface PersonT5 {
    name: string;
    age: number;
}

interface CustomerT5 extends PersonT5 {
    getPastOrderIds(): string[];
    computeTotalOrderValue(): number;
}

// instead of using multiple implements, we can extend these interfaces into a single interface then implement it.
// class CustomerT5 implements CustomerT5  = class CustomerT4 implements PersonT4, CustomerDetailedT4
class CustomerT5 implements CustomerT5 {
    constructor(private readonly id: string, public name: string, public age: number) {}

    getPastOrderIds(): string[] {
        return ["1", "2"];
    }

    computeTotalOrderValue(): number {
        return 100;
    }
}
  • you can use classes to implement types as if it implementing an interface
type PersonT = {};
interface PersonI {}

class Person1 implements PersonT {} // works
class Person2 implements PersonI {} // works
  • interfaces can also extend types
type PersonT = {
    name: string;
    age: number;
};

// PersonT is a type, not an interface, and yet CustomerT5 extends PersonT fine !!
interface CustomerT extends PersonT {
    getPastOrderIds(): string[];
    computeTotalOrderValue(): number;
}
  • There is no JavaScript equivalent to interfaces, and no details of interfaces are included in the JavaScript code generated by the TypeScript compiler.
  • instanceof keyword cannot be used to narrow interface types,

index signature

  • index signature allows to dynamically assign/access properties of an object.
  • index name would be a variable as in [index: string]: TypeT
// index signatures
interface ProductI {
    name: string;
    price: number;
}

class SportsProductI implements ProductI {
    constructor(public name: string, public price: number) {}
}

class ProductRepository {
    randomThing: string = "this will issues";
    constructor(...initialProducts: [string, ProductI][]) {
        initialProducts.forEach(([id, product]) => this.add(id, product));
    }
    add(id: string, product: ProductI) {
        this[id] = product; // index signature, properties are dynamically added to the object
    }
    get(id): ProductI[] {
        return this[id]; // index signature, properties are dynamically accessed from the object
    }
}

const productRepository = new ProductRepository(["1", new SportsProductI("Football", 100)]);
const p1 = productRepository.get("1");
const p2 = productRepository.get("2");
console.log({ p1, p2 });
// { p1: SportsProductI { name: 'Football', price: 100 }, p2: undefined }

// -------------------------
productRepository.add("3", new SportsProductI("Basketball", 200)); // dynamically adds a property to the object with the given id
const p3 = productRepository.get("3");
console.log({ p3 });
// { p3: SportsProductI { name: 'Basketball', price: 200 } }

// ------------------------- // index signature is not a good idea in classes
const pRandomThing = productRepository.get("randomThing"); // this will issues
console.log({ pRandomThing }); //{ pRandomThing: 'this will issues' }
// ProductRepository.get can give you any instance variable defined on its object, and that may not be of ProductI type.
// better to avoid in classes, but it is useful in lateral objects.