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:
| Modifier | Can be touched from |
|---|---|
public (the default) | Anywhere at all |
protected | The class, and any subclass of it |
private | Only the exact class where it's declared — subclasses don't get it either |
readonly | Not 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.