Classes #

Classes are the main mechanism of object-oriented programming (OOP) — they combine data (properties) and behavior (methods) into one cohesive unit. TypeScript takes JavaScript classes to the next level by adding access modifiers, readonly properties, abstract methods, and seamless integration with the type system through interface. However, classes aren’t the only way to organize code in TypeScript — and they aren’t always the right choice. Understanding classes in TypeScript means understanding when they’re useful, how to design them with good principles, and when it’s better to use other approaches like functions or plain objects.

The Anatomy of a TypeScript Class #

A TypeScript class can have many components. Here’s a complete overview of one representative class:

class Rekening {
  // 1. Properties with access modifiers
  private saldo: number;
  protected pemilik: string;
  public readonly nomorRekening: string;

  // 2. Static property — belongs to the class, not instances
  private static jumlahRekening = 0;

  // 3. Constructor — called on new Rekening(...)
  constructor(pemilik: string, saldoAwal: number) {
    this.pemilik = pemilik;
    this.saldo = saldoAwal;
    this.nomorRekening = Rekening.buatNomor();
    Rekening.jumlahRekening++;
  }

  // 4. Instance method
  setor(jumlah: number): void {
    if (jumlah <= 0) throw new Error("Jumlah setor harus positif");
    this.saldo += jumlah;
  }

  // 5. Getter — accessed like a property, but can contain logic
  get saldoSaatIni(): number {
    return this.saldo;
  }

  // 6. Static method — called through the class name
  static get totalRekening(): number {
    return Rekening.jumlahRekening;
  }

  private static buatNomor(): string {
    return `RK-${Date.now()}-${Math.random().toString(36).slice(2, 6).toUpperCase()}`;
  }
}

const rek = new Rekening("Budi", 1_000_000);
rek.setor(500_000);
console.log(rek.saldoSaatIni);         // 1500000
console.log(rek.nomorRekening);        // "RK-1746..."
console.log(Rekening.totalRekening);   // 1

Shorthand Constructor Parameters #

TypeScript provides a concise syntax that combines property declaration and initialization in a single line in the constructor — significantly reducing boilerplate:

// ANTI-PATTERN: Verbose — declaration and initialization are separate
class PenggunaPanjang {
  nama: string;
  email: string;
  private usia: number;

  constructor(nama: string, email: string, usia: number) {
    this.nama = nama;
    this.email = email;
    this.usia = usia;
  }
}

// CORRECT: Shorthand — declare directly in the constructor parameters
class Pengguna {
  constructor(
    public nama: string,
    public email: string,
    private usia: number,
    protected peran: string = "pengguna",
    public readonly id: string = crypto.randomUUID()
  ) {}

  info(): string {
    return `${this.nama} <${this.email}> [${this.peran}]`;
  }
}

const pengguna = new Pengguna("Budi", "[email protected]", 25);
console.log(pengguna.info()); // "Budi <[email protected]> [pengguna]"
// pengguna.id = "lain"; // ✗ Error: Cannot assign to 'id' because it is a read-only property

Access Modifiers: public, private, protected #

Access modifiers control where a property or method can be accessed. TypeScript enforces these rules at compile time:

class KonfigurasiDatabase {
  public host: string;           // Accessible from anywhere
  protected port: number;        // Only this class and its subclasses
  private password: string;      // Only inside this class
  readonly namaDatabase: string; // Can be read, can't be changed

  constructor(host: string, port: number, password: string, namaDb: string) {
    this.host = host;
    this.port = port;
    this.password = password;
    this.namaDatabase = namaDb;
  }

  buatKoneksiString(): string {
    // Can access everything — we're inside the class
    return `postgresql://${this.host}:${this.port}/${this.namaDatabase}`;
    // The password is deliberately not included in the connection string for security
  }
}

const dbKonfig = new KonfigurasiDatabase("localhost", 5432, "r4h4s1a", "muslimapps");
console.log(dbKonfig.host);          // ✓ public
console.log(dbKonfig.namaDatabase);  // ✓ readonly
// dbKonfig.port;                    // ✗ Error: 'port' is protected
// dbKonfig.password;                // ✗ Error: 'password' is private
// dbKonfig.namaDatabase = "lain";   // ✗ Error: read-only

JavaScript Private Fields (#) vs TypeScript private #

TypeScript 4.3+ supports native JavaScript private fields using #. The crucial difference: TypeScript private only exists at compile time, while # is truly private at the JavaScript runtime level:

class KontainerRahasia {
  private tipeTS: string;  // TypeScript private — compile-time only
  #tipeJS: string;          // JavaScript private — truly private at runtime

  constructor(nilai: string) {
    this.tipeTS = nilai;
    this.#tipeJS = nilai;
  }
}

const k = new KontainerRahasia("rahasia");

// In the compiled JavaScript output:
// (k as any).tipeTS; // ✓ Accessible! TypeScript only protects at compile time
// (k as any)["#tipeJS"]; // ✗ Can't — the private field is truly hidden

Getters and Setters #

Getters and setters are special methods called like regular properties. Getters read a value (possibly with logic), setters write a value with validation:

class TemperatureSensor {
  private _celsius: number;

  constructor(celsius: number) {
    this._celsius = celsius;
  }

  // Getter — read a property with logic
  get celsius(): number {
    return this._celsius;
  }

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

  get kelvin(): number {
    return this._celsius + 273.15;
  }

  // Setter — write with validation
  set celsius(nilai: number) {
    if (nilai < -273.15) {
      throw new RangeError("Temperatur tidak bisa di bawah nol absolut (-273.15°C)");
    }
    this._celsius = nilai;
  }
}

const sensor = new TemperatureSensor(100);
console.log(sensor.celsius);     // 100
console.log(sensor.fahrenheit);  // 212
console.log(sensor.kelvin);      // 373.15

sensor.celsius = 37; // Calls the setter
// sensor.celsius = -300; // ✗ Throws a RangeError at runtime

Inheritance with extends and super #

Inheritance lets a child class inherit all properties and methods from a parent class, then add or change its behavior:

class Hewan {
  constructor(
    protected nama: string,
    protected jenisKelamin: "jantan" | "betina"
  ) {}

  bergerak(jarakMeter: number): void {
    console.log(`${this.nama} bergerak ${jarakMeter} meter`);
  }

  bersuara(): void {
    console.log(`${this.nama} mengeluarkan suara`);
  }

  deskripsi(): string {
    return `${this.nama} (${this.jenisKelamin})`;
  }
}

class Kucing extends Hewan {
  private warnaBulu: string;

  constructor(nama: string, jenisKelamin: "jantan" | "betina", warnaBulu: string) {
    super(nama, jenisKelamin); // Must call super() before accessing this
    this.warnaBulu = warnaBulu;
  }

  // Override the parent method
  bersuara(): void {
    console.log(`${this.nama}: Meow!`);
  }

  // Add a new method
  mendengkur(): void {
    console.log(`${this.nama}: Purrr...`);
  }

  // Extend the parent method
  override deskripsi(): string {
    return `${super.deskripsi()}, bulu ${this.warnaBulu}`;
  }
}

const kucing = new Kucing("Mochi", "betina", "oranye");
kucing.bergerak(5);           // "Mochi bergerak 5 meter"
kucing.bersuara();            // "Mochi: Meow!" — override
kucing.mendengkur();          // "Mochi: Purrr..."
console.log(kucing.deskripsi()); // "Mochi (betina), bulu oranye"

// instanceof works along the whole inheritance chain
console.log(kucing instanceof Kucing); // true
console.log(kucing instanceof Hewan);  // true

The override Keyword (TypeScript 4.3+) #

Adding override explicitly on a method that overrides a parent method makes TypeScript validate that the method really exists on the parent class:

class KucingAman extends Hewan {
  // ✓ TypeScript verifies that Hewan has the 'bersuara' method
  override bersuara(): void {
    console.log("Meow!");
  }

  // ✗ Error: "tidur" doesn't exist on Hewan — typos are detected!
  // override tidur(): void { ... }
}

Static Members — Belong to the Class, Not Instances #

static properties and methods aren’t bound to any instance — they live on the class itself. Useful for factory methods, utilities, and singletons:

class IDGenerator {
  private static counter = 0;
  private static readonly PREFIX = "ID";

  // Factory method — an idiomatic way to create instances with logic
  static buat(): IDGenerator {
    return new IDGenerator();
  }

  static berikutnya(): string {
    IDGenerator.counter++;
    return `${IDGenerator.PREFIX}-${String(IDGenerator.counter).padStart(6, "0")}`;
  }

  static reset(): void {
    IDGenerator.counter = 0;
  }
}

console.log(IDGenerator.berikutnya()); // "ID-000001"
console.log(IDGenerator.berikutnya()); // "ID-000002"
console.log(IDGenerator.berikutnya()); // "ID-000003"

// Singleton pattern using static
class KonfigurasiApp {
  private static instance: KonfigurasiApp | null = null;
  private readonly pengaturan: Map<string, string>;

  private constructor() {
    this.pengaturan = new Map([
      ["tema", "gelap"],
      ["bahasa", "id"],
    ]);
  }

  static getInstance(): KonfigurasiApp {
    if (!KonfigurasiApp.instance) {
      KonfigurasiApp.instance = new KonfigurasiApp();
    }
    return KonfigurasiApp.instance;
  }

  ambil(kunci: string): string | undefined {
    return this.pengaturan.get(kunci);
  }
}

const konfig1 = KonfigurasiApp.getInstance();
const konfig2 = KonfigurasiApp.getInstance();
console.log(konfig1 === konfig2); // true — the same instance

Abstract Classes — Implementation Contracts #

Abstract classes can’t be instantiated directly — they’re templates that subclasses must implement. The key difference from interfaces: abstract classes can have concrete implementations alongside abstract methods:

abstract class PenyimpananData {
  // Abstract methods — MUST be implemented by subclasses
  abstract simpan(kunci: string, nilai: string): Promise<void>;
  abstract ambil(kunci: string): Promise<string | null>;
  abstract hapus(kunci: string): Promise<void>;

  // Concrete methods — already implemented, can be overridden or not
  async simpanJSON<T>(kunci: string, nilai: T): Promise<void> {
    await this.simpan(kunci, JSON.stringify(nilai));
  }

  async ambilJSON<T>(kunci: string): Promise<T | null> {
    const raw = await this.ambil(kunci);
    if (raw === null) return null;
    return JSON.parse(raw) as T;
  }
}

// Concrete implementation for Redis
class PenyimpananRedis extends PenyimpananData {
  constructor(private readonly url: string) {
    super();
  }

  async simpan(kunci: string, nilai: string): Promise<void> {
    console.log(`[Redis] SET ${kunci} = ${nilai}`);
    // Actual Redis implementation here
  }

  async ambil(kunci: string): Promise<string | null> {
    console.log(`[Redis] GET ${kunci}`);
    return null; // Simulation: no data
  }

  async hapus(kunci: string): Promise<void> {
    console.log(`[Redis] DEL ${kunci}`);
  }
}

// Concrete implementation for memory (for testing)
class PenyimpananMemori extends PenyimpananData {
  private store = new Map<string, string>();

  async simpan(kunci: string, nilai: string): Promise<void> {
    this.store.set(kunci, nilai);
  }

  async ambil(kunci: string): Promise<string | null> {
    return this.store.get(kunci) ?? null;
  }

  async hapus(kunci: string): Promise<void> {
    this.store.delete(kunci);
  }
}

// new PenyimpananData(); // ✗ Error: Cannot create an instance of an abstract class
const storage = new PenyimpananMemori();
await storage.simpanJSON("pengguna", { nama: "Budi", usia: 25 });
const data = await storage.ambilJSON("pengguna");

Implementing Interfaces #

Classes can implement one or more interfaces with the implements keyword. This ensures the class satisfies the type contract set by the interface:

interface BisaDiedit {
  edit(data: Partial<this>): void;
  validasi(): boolean;
}

interface BisaDihapus {
  hapus(): void;
  pulihkan(): void;
}

interface Entitas {
  readonly id: string;
  dibuatPada: Date;
  diperbarui: Date;
}

// A class can implement multiple interfaces at once
class Artikel implements Entitas, BisaDiedit, BisaDihapus {
  readonly id: string;
  dibuatPada: Date;
  diperbarui: Date;
  private terhapus = false;

  constructor(
    public judul: string,
    public konten: string
  ) {
    this.id = crypto.randomUUID();
    this.dibuatPada = new Date();
    this.diperbarui = new Date();
  }

  edit(data: Partial<Artikel>): void {
    if (data.judul !== undefined) this.judul = data.judul;
    if (data.konten !== undefined) this.konten = data.konten;
    this.diperbarui = new Date();
  }

  validasi(): boolean {
    return this.judul.length >= 5 && this.konten.length >= 50;
  }

  hapus(): void {
    this.terhapus = true;
    console.log(`Artikel "${this.judul}" ditandai terhapus`);
  }

  pulihkan(): void {
    this.terhapus = false;
    console.log(`Artikel "${this.judul}" dipulihkan`);
  }
}

Mixins — Horizontal Composition #

TypeScript doesn’t support multiple inheritance (inheriting from more than one class), but it does support mixins — a way to compose behavior from several sources without an inheritance hierarchy:

// Type for a constructor
type Constructor<T = {}> = new (...args: any[]) => T;

// Mixin 1: Adds logging capability
function DenganLogging<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    log(pesan: string): void {
      console.log(`[${new Date().toISOString()}] ${pesan}`);
    }
  };
}

// Mixin 2: Adds serialization capability
function DenganSerialisasi<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    keJSON(): string {
      return JSON.stringify(this);
    }

    static dariJSON<T>(json: string): T {
      return JSON.parse(json) as T;
    }
  };
}

// Base class
class KomponenDasar {
  constructor(public nama: string) {}
}

// Mixin composition — combine several capabilities
const KomponenLengkap = DenganLogging(DenganSerialisasi(KomponenDasar));

const komponen = new KomponenLengkap("KomponenSaya");
komponen.log("Komponen diinisialisasi");  // From DenganLogging
console.log(komponen.keJSON());            // From DenganSerialisasi

Class Hierarchy — Visualization #

classDiagram
    class PenyimpananData {
        <<abstract>>
        +simpanJSON(kunci, nilai) Promise
        +ambilJSON(kunci) Promise
        #simpan(kunci, nilai)* Promise
        #ambil(kunci)* Promise
        #hapus(kunci)* Promise
    }

    class PenyimpananRedis {
        -url: string
        +simpan(kunci, nilai) Promise
        +ambil(kunci) Promise
        +hapus(kunci) Promise
    }

    class PenyimpananMemori {
        -store: Map
        +simpan(kunci, nilai) Promise
        +ambil(kunci) Promise
        +hapus(kunci) Promise
    }

    class Entitas {
        <<interface>>
        +id: string
        +dibuatPada: Date
        +diperbarui: Date
    }

    class BisaDiedit {
        <<interface>>
        +edit(data) void
        +validasi() boolean
    }

    class Artikel {
        +judul: string
        +konten: string
        -terhapus: boolean
        +edit(data) void
        +validasi() boolean
        +hapus() void
        +pulihkan() void
    }

    PenyimpananData <|-- PenyimpananRedis : extends
    PenyimpananData <|-- PenyimpananMemori : extends
    Entitas <|.. Artikel : implements
    BisaDiedit <|.. Artikel : implements

Summary #

  • Shorthand constructor parameters — declare properties directly in the constructor parameters with access modifiers (public, private, protected, readonly) to significantly reduce boilerplate.
  • TypeScript private vs JavaScript #private only exists at compile time and can be breached with as any; # is a JavaScript private field that’s truly hidden at runtime; use # if you need real encapsulation.
  • Getters and setters enable property access that looks like a regular property but can contain validation or transformation logic; very useful for derived properties and properties with input validation.
  • The override keyword (TypeScript 4.3+) makes TypeScript validate that the overridden method really exists on the parent class — preventing bugs from method name typos.
  • Abstract classes suit cases where there’s default implementation to share across all subclasses, alongside some methods each must implement; unlike interfaces, which are pure contracts without implementation.
  • Static members for factory methods, singletons, and utilities that don’t need instance state — accessed via the class name, not an instance.
  • Implementing multiple interfaces lets a class satisfy several contracts at once without being tied to a rigid inheritance hierarchy.
  • Mixins for horizontal composition — TypeScript doesn’t support multiple inheritance, but mixins let you compose behavior from several sources; more flexible than deep inheritance hierarchies.
  • Prefer composition over inheritance — deep inheritance (A extends B extends C extends D) makes code hard to change and test; better to use composition (mixins, dependency injection) to share behavior.

← Previous: Functions   Next: Interface →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact