Polyglot
Languages TypeScript oop
TypeScript § oop

Classes and OOP

TypeScript adds substantial OOP machinery to JavaScript’s prototype-based class system: access modifiers (public, private, protected), readonly properties, abstract classes, interfaces, parameter properties (compact constructor declarations), getters and setters, static members, and generic classes. The class system is single-inheritance — a class extends at most one other class — but admits implementing multiple interfaces. The principal contemporary discipline favours composition and interfaces over deep inheritance hierarchies; classes are conventional for stateful entities, framework-required base classes, and substantial encapsulation.

Class declarations

The principal form:

class Counter {
    count: number;

    constructor(initial: number = 0) {
        this.count = initial;
    }

    increment(): void {
        this.count++;
    }

    get(): number {
        return this.count;
    }
}

const c = new Counter();
c.increment();
console.log(c.get());                            // 1

The constructor is the special initialiser; this refers to the instance. Methods are declared without function.

Properties and initialisation

Properties may be declared with explicit types and initialisers:

class User {
    name: string = "";
    age: number = 0;
    email?: string;                               // optional
    readonly id: string;                          // immutable

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

Under strictPropertyInitialization: true (part of strict), every non-optional property must be initialised — either at declaration, in the constructor, or marked with ! (definite assignment assertion):

class User {
    name!: string;                                // assert it will be initialised elsewhere
    // ... e.g., by a framework
}

The conventional discipline initialises in the constructor.

Parameter properties

A compact form: declare and assign in the constructor signature:

class Point {
    constructor(public x: number, public y: number) {}
}

const p = new Point(1, 2);
console.log(p.x, p.y);                           // 1 2

The public x: number declares a public property x and assigns the constructor argument to it. The form admits substantial conciseness for data classes; the access modifier (public, private, protected, readonly) is required for parameter properties.

Access modifiers

class User {
    public name: string;                          // visible everywhere
    private password: string;                     // visible only within User
    protected role: string;                       // visible in User and subclasses
    readonly id: string;                          // visible everywhere; immutable

    constructor(name: string, password: string, role: string, id: string) {
        this.name = name;
        this.password = password;
        this.role = role;
        this.id = id;
    }
}

const u = new User("Alice", "pwd", "admin", "u-1");
u.name;                                           // OK
// u.password;                                     // ERROR: private
// u.role;                                         // ERROR: protected
u.id;                                             // OK
// u.id = "u-2";                                   // ERROR: readonly

The modifiers are compile-time checks — they are erased during emission. The runtime JavaScript admits accessing password directly. For runtime privacy, the JavaScript-native # private fields are conventional.

JavaScript-native private fields

Since ES2022, the # prefix admits truly private fields:

class User {
    #password: string;                            // truly private at runtime

    constructor(password: string) {
        this.#password = password;
    }

    verify(input: string): boolean {
        return input === this.#password;
    }
}

const u = new User("secret");
// u.#password;                                    // ERROR (and at runtime)

The # form admits runtime privacy; the TypeScript private is compile-time only. The conventional contemporary discipline uses # for genuinely private fields and private for the conventional access-control discipline.

Inheritance

The extends keyword admits inheriting from a parent class:

class Animal {
    constructor(public name: string) {}

    speak(): string {
        return "...";
    }
}

class Dog extends Animal {
    constructor(name: string, public breed: string) {
        super(name);                              // call parent constructor
    }

    override speak(): string {                    // explicitly override
        return `${this.name} says woof`;
    }

    fetch(): void {
        console.log(`${this.name} fetches the ball`);
    }
}

const d = new Dog("Rex", "Labrador");
console.log(d.speak());                          // "Rex says woof"
d.fetch();

The super(...) call invokes the parent constructor; it must precede any access to this.

The override keyword (since TS 4.3) is conventional — under noImplicitOverride: true, it is required for methods that override a parent method.

Abstract classes

The abstract keyword introduces classes that cannot be instantiated directly:

abstract class Shape {
    abstract area(): number;                      // abstract method (no body)

    describe(): string {                          // concrete method
        return `Shape with area ${this.area()}`;
    }
}

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

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

// const s = new Shape();                          // ERROR: cannot instantiate abstract
const c = new Circle(5);
console.log(c.describe());                       // "Shape with area 78.5..."

Subclasses must implement all abstract members; the form admits enforcing a contract.

Interfaces

Interfaces declare types — they exist at the type level only:

interface Describable {
    describe(): string;
}

interface Serializable {
    serialize(): string;
}

class User implements Describable, Serializable {
    constructor(public name: string, public age: number) {}

    describe(): string {
        return `${this.name} (${this.age})`;
    }

    serialize(): string {
        return JSON.stringify({ name: this.name, age: this.age });
    }
}

implements checks that the class satisfies the interface; multiple interfaces admit multiple-contract conformance.

The conventional contemporary discipline:

  • Use interfaces for public contracts and shared shapes.
  • Use abstract classes when shared implementation is needed (not just contract).
  • Prefer composition over inheritance.

Getters and setters

The get and set keywords admit accessor methods:

class Temperature {
    private _celsius: number = 0;

    get celsius(): number {
        return this._celsius;
    }

    set celsius(value: number) {
        if (value < -273.15) throw new RangeError("below absolute zero");
        this._celsius = value;
    }

    get fahrenheit(): number {
        return this._celsius * 9 / 5 + 32;
    }

    set fahrenheit(value: number) {
        this._celsius = (value - 32) * 5 / 9;
    }
}

const t = new Temperature();
t.celsius = 25;
console.log(t.fahrenheit);                       // 77
t.fahrenheit = 100;
console.log(t.celsius);                          // 37.78

Getters and setters admit “looks like a property, runs code”. The conventional discipline uses them for derived properties and validation.

static members

The static keyword introduces class-level (not instance-level) members:

class MathUtils {
    static readonly PI = 3.14159;

    static circleArea(radius: number): number {
        return MathUtils.PI * radius ** 2;
    }
}

console.log(MathUtils.PI);
console.log(MathUtils.circleArea(5));

Static members are accessed via the class name, not an instance.

Since ES2022, static blocks admit substantial initialisation:

class Config {
    static readonly settings: Record<string, string> = {};

    static {
        for (const key of Object.keys(process.env)) {
            if (key.startsWith("APP_")) {
                Config.settings[key.slice(4).toLowerCase()] = process.env[key]!;
            }
        }
    }
}

Generic classes

Type parameters admit generic classes:

class Stack<T> {
    private items: T[] = [];

    push(x: T): void {
        this.items.push(x);
    }

    pop(): T | undefined {
        return this.items.pop();
    }

    peek(): T | undefined {
        return this.items[this.items.length - 1];
    }
}

const s = new Stack<number>();
s.push(1);
s.push(2);
console.log(s.pop());                            // 2

Treated in Generics.

this types

The this type admits referring to the current class:

class Builder {
    private parts: string[] = [];

    add(part: string): this {                     // returns the same type as the receiver
        this.parts.push(part);
        return this;
    }

    build(): string {
        return this.parts.join(" ");
    }
}

class QueryBuilder extends Builder {
    select(field: string): this {
        return this.add(`SELECT ${field}`);
    }
}

const qb = new QueryBuilder();
qb.select("name")
    .add("FROM users")
    .build();

The this return type admits method chaining that respects subtyping — select on a QueryBuilder returns QueryBuilder, not Builder.

Class type vs instance type

A class produces both a constructor type and an instance type:

class User {
    constructor(public name: string) {}
}

type UserInstance = User;                         // the instance type
type UserConstructor = typeof User;               // the constructor type

function createUser(C: UserConstructor, name: string): UserInstance {
    return new C(name);
}

The pattern admits factory-style construction.

Mixins

Mixins admit composition through subclassing:

type Constructor<T = {}> = new (...args: any[]) => T;

function Timestamped<TBase extends Constructor>(Base: TBase) {
    return class extends Base {
        timestamp = Date.now();
    };
}

function Tagged<TBase extends Constructor>(Base: TBase) {
    return class extends Base {
        tags: string[] = [];

        addTag(tag: string): void {
            this.tags.push(tag);
        }
    };
}

class Event {
    constructor(public name: string) {}
}

class TaggedTimestampedEvent extends Tagged(Timestamped(Event)) {}

const e = new TaggedTimestampedEvent("click");
e.addTag("important");
console.log(e.timestamp, e.tags);

The pattern admits substantial composition without single-inheritance constraints; conventional in libraries that need flexible composition.

Common patterns

Data class with parameter properties

class User {
    constructor(
        public readonly id: string,
        public name: string,
        public email: string,
        public age: number,
    ) {}

    rename(newName: string): User {
        return new User(this.id, newName, this.email, this.age);
    }
}

Validation in constructor

class Email {
    constructor(public readonly address: string) {
        if (!Email.isValid(address)) {
            throw new Error(`Invalid email: ${address}`);
        }
    }

    static isValid(s: string): boolean {
        return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s);
    }
}

Private factory pattern

class User {
    private constructor(
        public readonly id: string,
        public name: string,
    ) {}

    static create(name: string): User {
        return new User(crypto.randomUUID(), name);
    }
}

const u = User.create("Alice");
// const u = new User(...);                        // ERROR: constructor is private

Singleton

class Logger {
    private static instance: Logger | null = null;

    private constructor() {}

    static getInstance(): Logger {
        if (Logger.instance === null) {
            Logger.instance = new Logger();
        }
        return Logger.instance;
    }

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

const logger = Logger.getInstance();

Builder pattern

class HttpRequestBuilder {
    private url = "";
    private method = "GET";
    private headers: Record<string, string> = {};
    private body?: string;

    withUrl(url: string): this {
        this.url = url;
        return this;
    }

    withMethod(method: string): this {
        this.method = method;
        return this;
    }

    withHeader(key: string, value: string): this {
        this.headers[key] = value;
        return this;
    }

    withBody(body: string): this {
        this.body = body;
        return this;
    }

    build(): { url: string; method: string; headers: Record<string, string>; body?: string } {
        return {
            url: this.url,
            method: this.method,
            headers: this.headers,
            body: this.body,
        };
    }
}

const req = new HttpRequestBuilder()
    .withUrl("/api/users")
    .withMethod("POST")
    .withHeader("Content-Type", "application/json")
    .withBody('{"name":"alice"}')
    .build();

Strategy pattern

interface SortStrategy<T> {
    sort(items: T[]): T[];
}

class QuickSort<T> implements SortStrategy<T> {
    constructor(private compare: (a: T, b: T) => number) {}
    sort(items: T[]): T[] { /* ... */ return items; }
}

class MergeSort<T> implements SortStrategy<T> {
    constructor(private compare: (a: T, b: T) => number) {}
    sort(items: T[]): T[] { /* ... */ return items; }
}

class Sorter<T> {
    constructor(private strategy: SortStrategy<T>) {}

    sort(items: T[]): T[] {
        return this.strategy.sort(items);
    }
}

const s = new Sorter(new QuickSort<number>((a, b) => a - b));

Observer pattern

type Listener<E> = (event: E) => void;

class EventBus<E> {
    private listeners: Listener<E>[] = [];

    on(listener: Listener<E>): () => void {
        this.listeners.push(listener);
        return () => {
            const i = this.listeners.indexOf(listener);
            if (i >= 0) this.listeners.splice(i, 1);
        };
    }

    emit(event: E): void {
        for (const listener of this.listeners) {
            listener(event);
        }
    }
}

const bus = new EventBus<{ type: string; data: unknown }>();
const off = bus.on(e => console.log(e.type, e.data));
bus.emit({ type: "click", data: { x: 1, y: 2 } });
off();                                            // unsubscribe

Abstract base class

abstract class Repository<T extends { id: string }> {
    abstract find(id: string): Promise<T | null>;
    abstract save(item: T): Promise<void>;

    async exists(id: string): Promise<boolean> {
        return (await this.find(id)) !== null;
    }
}

class UserRepository extends Repository<User> {
    async find(id: string): Promise<User | null> {
        // ...
        return null;
    }

    async save(user: User): Promise<void> {
        // ...
    }
}

Discriminated union via class hierarchy

abstract class Shape {
    abstract readonly kind: string;
    abstract area(): number;
}

class Circle extends Shape {
    readonly kind = "circle" as const;
    constructor(public radius: number) { super(); }
    area(): number { return Math.PI * this.radius ** 2; }
}

class Square extends Shape {
    readonly kind = "square" as const;
    constructor(public side: number) { super(); }
    area(): number { return this.side ** 2; }
}

function describe(s: Shape): string {
    switch (s.kind) {
        case "circle": return `Circle r=${(s as Circle).radius}`;
        case "square": return `Square s=${(s as Square).side}`;
        default: return "unknown";
    }
}

The conventional contemporary alternative is plain discriminated unions (treated in Narrowing).

A note on the conventional discipline

The contemporary TypeScript class advice:

  • Use classes for stateful entities and framework-required base classes.
  • Prefer composition over inheritance.
  • Use interfaces for public contracts.
  • Use abstract classes when shared implementation is needed.
  • Use parameter properties for compact data classes.
  • Use readonly for immutable properties.
  • Use # for runtime privacy; private for compile-time access control.
  • Use override (under noImplicitOverride) for clear intent.
  • Use static for class-level constants and factory methods.
  • Use generics for genuinely generic classes.
  • Use this return types for method chaining.

The combination — classes with access modifiers, abstract classes, interfaces, parameter properties, getters/setters, generic classes, mixins via higher-order class functions — is the substance of TypeScript’s OOP surface. The discipline trades some of OOP’s substantive features (multiple inheritance, runtime polymorphism via virtual tables) for structural typing and substantial type-level expressiveness.