On this page

Classes and decorators

14 min read TextCh. 5 — TypeScript in Practice

Classes in TypeScript

TypeScript builds on JavaScript classes by adding a type system layer: access modifiers, readonly properties, parameter properties, abstract classes, and static members. All of this compiles away to standard JavaScript — classes are not a TypeScript-only concept at runtime.

Access modifiers

TypeScript provides three access modifiers that control where a property or method can be accessed:

Modifier Accessible in Compile-time only?
public Everywhere (default) No
protected Class and subclasses Yes
private Class only Yes

Additionally, JavaScript's #name syntax provides hard private fields enforced at runtime:

class Counter {
  #count = 0; // true runtime privacy

  increment(): void { this.#count++; }
  get value(): number { return this.#count; }
}

const c = new Counter();
c.increment();
console.log(c.value); // 1
// (c as any).#count  // SyntaxError at runtime — not just a type error

Parameter properties

TypeScript's parameter properties let you declare and initialize class members directly in the constructor signature, eliminating boilerplate:

// Without parameter properties
class UserVerbose {
  public name: string;
  private email: string;
  readonly id: number;

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

// With parameter properties — identical result
class User {
  constructor(
    public name: string,
    private email: string,
    readonly id: number
  ) {}
}

Both classes are equivalent. The shorthand form is idiomatic TypeScript.

Readonly properties

A readonly property can only be assigned during declaration or in the constructor. Attempts to reassign it elsewhere are compile errors:

class Config {
  readonly maxRetries: number;
  readonly baseUrl: string;

  constructor(maxRetries: number, baseUrl: string) {
    this.maxRetries = maxRetries;
    this.baseUrl    = baseUrl;
  }
}

const config = new Config(3, "https://api.example.com");
// config.maxRetries = 5; // Error: cannot assign to 'maxRetries' — it is a read-only property

Static members

Static properties and methods belong to the class itself, not to instances. They are useful for factory methods, counters, and shared utilities:

class IdGenerator {
  private static nextId = 1;

  static generate(): number {
    return IdGenerator.nextId++;
  }

  static reset(): void {
    IdGenerator.nextId = 1;
  }
}

console.log(IdGenerator.generate()); // 1
console.log(IdGenerator.generate()); // 2

Abstract classes

An abstract class defines a contract that subclasses must fulfill. You cannot create an instance of an abstract class directly — it exists only to be extended:

abstract class Transport {
  abstract send(payload: string): Promise<void>;

  async sendJson(data: unknown): Promise<void> {
    await this.send(JSON.stringify(data));
  }
}

class HttpTransport extends Transport {
  constructor(private url: string) { super(); }

  async send(payload: string): Promise<void> {
    await fetch(this.url, {
      method: "POST",
      body: payload,
      headers: { "Content-Type": "application/json" },
    });
  }
}

class LogTransport extends Transport {
  async send(payload: string): Promise<void> {
    console.log("[LogTransport]", payload);
  }
}

Both HttpTransport and LogTransport inherit the concrete sendJson method for free and only need to implement send.

The `implements` keyword

A class can implement one or more interfaces. This enforces the contract at compile time without affecting the runtime prototype chain:

interface Serializable {
  serialize(): string;
  deserialize(raw: string): void;
}

interface Loggable {
  log(): void;
}

class Session implements Serializable, Loggable {
  private data: Record<string, string> = {};

  serialize(): string {
    return JSON.stringify(this.data);
  }

  deserialize(raw: string): void {
    this.data = JSON.parse(raw) as Record<string, string>;
  }

  log(): void {
    console.log("Session:", this.data);
  }
}

TC39 decorators

Decorators are a Stage 3 TC39 proposal, natively supported in TypeScript 5.0+ without any compiler flags. A decorator is a function that receives the target it decorates (a class, method, property, or accessor) along with a context object describing it.

Method decorator

function memoize(
  target: unknown,
  context: ClassMethodDecoratorContext
): unknown {
  const cache = new Map<string, unknown>();

  return function (this: unknown, ...args: unknown[]): unknown {
    const key = JSON.stringify(args);
    if (cache.has(key)) {
      return cache.get(key);
    }
    const result = (target as (...a: unknown[]) => unknown).apply(this, args);
    cache.set(key, result);
    return result;
  };
}

class MathUtils {
  @memoize
  fibonacci(n: number): number {
    if (n <= 1) return n;
    return this.fibonacci(n - 1) + this.fibonacci(n - 2);
  }
}

Class decorator

function singleton<T extends new (...args: unknown[]) => unknown>(
  target: T,
  context: ClassDecoratorContext
): T {
  let instance: InstanceType<T> | undefined;
  return class extends target {
    constructor(...args: unknown[]) {
      if (instance) return instance;
      super(...args);
      instance = this as InstanceType<T>;
    }
  } as T;
}

@singleton
class AppConfig {
  theme = "dark";
}

const a = new AppConfig();
const b = new AppConfig();
console.log(a === b); // true

Property decorator

function validate(
  target: undefined,
  context: ClassFieldDecoratorContext
): (value: unknown) => unknown {
  return function (initialValue: unknown): unknown {
    if (typeof initialValue !== "string" || initialValue.trim() === "") {
      throw new Error(`Field "${String(context.name)}" must be a non-empty string`);
    }
    return initialValue;
  };
}

class Article {
  @validate
  title = "My first article";
}

Practice

  1. Access modifiers: Build a Queue<T> class with a private #items: T[]. Expose enqueue(item: T), dequeue(): T | undefined, and a readonly size getter. Verify that attempting to access #items directly from outside the class fails.
  2. Abstract class: Define an abstract Validator<T> class with an abstract validate(value: T): string[] method and a concrete isValid(value: T): boolean method that returns true when validate returns an empty array. Implement EmailValidator and PhoneValidator subclasses.
  3. Implements multiple interfaces: Create Printable and Exportable interfaces. Build a Report class that implements both, storing an array of strings and implementing print(): void and export(): string.
  4. Method decorator: Write a deprecated(message: string) decorator factory that logs a warning to the console the first time the decorated method is called. Apply it to a method and verify the warning appears exactly once.

Next lesson: modules and namespaces — organizing TypeScript code across files and packages.

Prefer TC39 decorators over the legacy experimentalDecorators flag
TypeScript 5.0 introduced support for TC39 Stage 3 decorators, which do not require the `experimentalDecorators` compiler flag and have stable, well-defined semantics. Use these for new projects. Legacy decorators (`experimentalDecorators: true`) are still supported for backward compatibility but are not recommended for new code.
Use
TypeScript's `private` keyword is a compile-time check only — at runtime the property is accessible. JavaScript's `#name` syntax (hard private fields) enforces privacy at runtime and is supported in TypeScript via the same syntax. For fields that must stay truly private (e.g., cryptographic keys, internal state), prefer `#` over `private`.
Abstract classes vs interfaces for shared behavior
Use an interface when you only want to describe the shape (contract with no implementation). Use an abstract class when you want to share default method implementations. An abstract class can have both abstract methods (must be overridden) and concrete methods (inherited as-is). A class can implement multiple interfaces but extend only one class.
typescript
class BankAccount {
  // public — accessible everywhere (default)
  readonly id: string;

  // private — only accessible inside BankAccount
  #balance: number; // TC39 private field (hard private)

  // protected — accessible in BankAccount and subclasses
  protected owner: string;

  // Parameter properties — shorthand for declare + assign
  constructor(
    public readonly bank: string,
    owner: string,
    initialBalance: number
  ) {
    this.id = crypto.randomUUID();
    this.owner = owner;
    this.#balance = initialBalance;
  }

  deposit(amount: number): void {
    if (amount <= 0) throw new RangeError("Amount must be positive");
    this.#balance += amount;
  }

  get balance(): number {
    return this.#balance;
  }
}

class SavingsAccount extends BankAccount {
  #interestRate: number;

  constructor(bank: string, owner: string, balance: number, rate: number) {
    super(bank, owner, balance);
    this.#interestRate = rate;
  }

  applyInterest(): void {
    const interest = this.balance * this.#interestRate;
    this.deposit(interest);
    // this.#balance  — Error: #balance is private to BankAccount
    // this.owner     — OK: protected, accessible in subclass
  }
}
typescript
// Abstract class — cannot be instantiated directly
abstract class Shape {
  abstract area(): number;
  abstract perimeter(): number;

  describe(): string {
    return `Area: ${this.area().toFixed(2)}, Perimeter: ${this.perimeter().toFixed(2)}`;
  }
}

class Circle extends Shape {
  constructor(private radius: number) { super(); }
  area(): number      { return Math.PI * this.radius ** 2; }
  perimeter(): number { return 2 * Math.PI * this.radius; }
}

class Rectangle extends Shape {
  constructor(private w: number, private h: number) { super(); }
  area(): number      { return this.w * this.h; }
  perimeter(): number { return 2 * (this.w + this.h); }
}

// TC39 Stage 3 decorators (TypeScript 5.0+ with experimentalDecorators: false)
function log(target: unknown, context: ClassMethodDecoratorContext): unknown {
  const methodName = String(context.name);
  return function(this: unknown, ...args: unknown[]): unknown {
    console.log(`[${methodName}] called with`, args);
    const result = (target as (...a: unknown[]) => unknown).apply(this, args);
    console.log(`[${methodName}] returned`, result);
    return result;
  };
}

class Calculator {
  @log
  add(a: number, b: number): number {
    return a + b;
  }

  @log
  multiply(a: number, b: number): number {
    return a * b;
  }
}

const calc = new Calculator();
calc.add(3, 4);       // logs: [add] called with [3, 4] ... [add] returned 7
calc.multiply(3, 4);  // logs: [multiply] called with [3, 4] ... returned 12