Skip to main content
CodeOath
← All posts

TypeScript63 min total · 21 parts

TypeScript Fundamentals: Types, Interfaces, Generics, and Why It Catches Bugs Before Runtime

Part 16 of 21 · ~3 min

Classes: Access Modifiers, Readonly, and Abstract

The generic Queue<T> from earlier proved the enqueue/dequeue mechanism works for any payload at all. What the queue actually needs to ship is narrower than that — committed to one concrete type, and locked down so the rest of the codebase can't reach in and mutate its internals directly:

class ApplicationQueue {
  private items: Applicant[] = [];
  readonly queueId: string;

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

  enqueue(applicant: Applicant): void {
    this.items.push(applicant);
  }

  dequeue(): Applicant | undefined {
    return this.items.shift();
  }

  get size(): number {
    return this.items.length; // fine from inside the class
  }
}

const engQueue = new ApplicationQueue("engineering");
engQueue.items;             // Error — items is private
engQueue.queueId = "sales"; // Error — queueId is readonly outside its own declaration/constructor

Four modifiers, four different reach:

ModifierCan be touched from
public (the default)Anywhere at all
protectedThe class, and any subclass of it
privateOnly the exact class where it's declared — subclasses don't get it either
readonlyNot about visibility — about assignment, allowed only in the declaration or constructor

protected is worth its own example, because it's specifically the modifier for "closed to the outside world, but open to a subclass building on top of the base behavior." A priority queue that reorders on every insert is still fundamentally a queue — it needs direct access to the underlying array rather than going through enqueue one call at a time:

class ApplicationQueueBase {
  protected items: Applicant[] = [];
  enqueue(applicant: Applicant): void { this.items.push(applicant); }
}

class PriorityQueue extends ApplicationQueueBase {
  enqueue(applicant: Applicant): void {
    this.items.push(applicant);
    this.items.sort((a, b) => (a.stage === "offer" ? -1 : 1)); // reaches items directly — protected allows this
  }
}

const pq = new PriorityQueue();
pq.items; // still an error out here — protected only opens the door to the class hierarchy, not the outside

There's a shorthand that skips declaring fields separately from the constructor that assigns them:

class ApplicationQueue2 {
  constructor(
    public readonly queueId: string,
    private items: Applicant[] = [],
  ) {}
}
// identical to writing both field declarations, then assigning them in the constructor body by hand

abstract class is for a base class that only makes sense as a starting point — you can require subclasses to implement specific methods without providing any default for them, while still sharing whatever logic doesn't depend on the missing piece. The queue's scoring step needs more than one strategy over time — a rough keyword match today, something weighted and more careful later — and both need the same summary format:

abstract class Scorer {
  abstract score(a: Applicant): number; // deliberately no body — every subclass must supply one
  describe(a: Applicant): string {
    return `${a.name}: ${this.score(a)}`;
  }
}

class KeywordScorer extends Scorer {
  constructor(private keywords: string[]) { super(); }
  score(a: Applicant): number {
    return this.keywords.filter((k) => a.name.includes(k)).length;
  }
}

new Scorer(); // Error — an abstract class can't be constructed directly, missing pieces or not

None of this — private, protected, readonly, abstract — leaves a trace once the code actually runs. It's all checked once, at compile time, and then discarded like every other type in this reference. engQueue.items fails to compile, but the exact same object, printed to a browser console or accessed as engQueue["items"], shows the array sitting right there, fully readable. Real runtime privacy is a different feature entirely — JavaScript's own #privateField syntax, which TypeScript also supports, and which genuinely is enforced while the program runs: a #-prefixed field can't be reached from outside the class by any means, including Object.keys or bracket notation, because the JavaScript engine itself refuses, not the compiler.