|$ curl https://forge-ai.dev/api/markdown?path=docs/typescript/abstract
$cat docs/typescript-—-abstract-&-access-modifiers.md
updated Recently·10 min read·published

TypeScript — Abstract & Access Modifiers

TypeScriptAdvanced
Introduction

TypeScript extends JavaScript's class system with two major categories of type-level controls: abstract classes for defining incomplete base types that require subclass implementation, and access modifiers for controlling visibility. Together they enable the classical OOP patterns (Template Method, Factory, Strategy) with full type safety.

While TypeScript's access modifiers are compile-time-only, the newer ES private fields (#) and TC39 decorators provide runtime enforcement. This guide covers both the classical TypeScript approach and the modern JavaScript-standard equivalents.

📝

note

Abstract classes are a TypeScript-only feature removed during compilation. They cannot be instantiated directly — only through concrete subclasses. The abstract keyword has no runtime representation.
Abstract Classes

An abstract class serves as a base class that cannot be instantiated on its own. It may contain both implemented methods and abstract method signatures that subclasses must implement. Use abstract keyword before the class declaration.

abstract-class.ts
TypeScript
1abstract class Shape {
2 // Concrete property — inherited by subclasses
3 readonly id: string;
4
5 constructor(id: string) {
6 this.id = id;
7 }
8
9 // Concrete method — optionally overridden
10 describe(): string {
11 return `Shape[${this.id}]: area=${this.getArea().toFixed(2)}`;
12 }
13
14 // Abstract method — must be implemented by subclasses
15 abstract getArea(): number;
16
17 // Abstract method with parameters
18 abstract scale(factor: number): Shape;
19}
20
21// const s = new Shape("s1"); // Error: Cannot create instance of abstract class
22
23class Circle extends Shape {
24 constructor(
25 id: string,
26 public radius: number
27 ) {
28 super(id);
29 }
30
31 // Must implement getArea
32 getArea(): number {
33 return Math.PI * this.radius * this.radius;
34 }
35
36 // Must implement scale
37 scale(factor: number): Circle {
38 return new Circle(`${this.id}-scaled`, this.radius * factor);
39 }
40}
41
42class Square extends Shape {
43 constructor(
44 id: string,
45 public side: number
46 ) {
47 super(id);
48 }
49
50 getArea(): number {
51 return this.side * this.side;
52 }
53
54 scale(factor: number): Square {
55 return new Square(`${this.id}-scaled`, this.side * factor);
56 }
57}
58
59const shapes: Shape[] = [new Circle("c1", 5), new Square("s1", 4)];
60shapes.forEach((s) => console.log(s.describe()));
61// Shape[c1]: area=78.54
62// Shape[s1]: area=16.00

best practice

Abstract classes are ideal for the Template Method pattern — define the skeleton in the abstract class with concrete methods that call abstract hooks. Subclasses supply the varying parts while the base class controls the algorithm flow.
Abstract Methods & Properties

Abstract methods have no implementation in the base class. Abstract properties can declare required get/set accessors that subclasses must provide. Both use the abstract keyword.

abstract-methods.ts
TypeScript
1abstract class DataSource {
2 // Abstract property — subclass must provide
3 abstract readonly name: string;
4
5 // Abstract getter — subclass controls implementation
6 abstract get connected(): boolean;
7
8 // Concrete method using abstract hooks
9 async fetch<T>(endpoint: string): Promise<T> {
10 if (!this.connected) {
11 await this.connect();
12 }
13 return this.request<T>(endpoint);
14 }
15
16 // Abstract method — return type varies
17 protected abstract connect(): Promise<void>;
18
19 protected abstract request<T>(endpoint: string): Promise<T>;
20
21 // Abstract method with no return
22 abstract disconnect(): void;
23}
24
25class APIDataSource extends DataSource {
26 readonly name = "API Data Source";
27 private _connected = false;
28
29 get connected(): boolean {
30 return this._connected;
31 }
32
33 protected async connect(): Promise<void> {
34 console.log("Connecting to API...");
35 this._connected = true;
36 }
37
38 protected async request<T>(endpoint: string): Promise<T> {
39 const res = await fetch(endpoint);
40 return res.json();
41 }
42
43 disconnect(): void {
44 this._connected = false;
45 console.log("Disconnected");
46 }
47}
48
49// Abstract constructors cannot be called directly
50// Abstract class can have constructors for shared initialization
51abstract class BaseEntity {
52 constructor(
53 public readonly id: string,
54 public readonly createdAt: Date
55 ) {
56 if (!id) throw new Error("ID is required");
57 }
58
59 abstract validate(): boolean;
60 abstract toJSON(): Record<string, unknown>;
61}

warning

Abstract methods cannot be private — they must be accessible to subclasses. Use protected abstract for methods that are implementation details but still need to be overridden.
Access Modifiers Deep Dive

Beyond basic visibility, TypeScript's access modifiers interact with inheritance, structural typing, and declaration files in nuanced ways. Understanding these interactions prevents subtle bugs.

modifiers-deep.ts
TypeScript
1class Base {
2 public a = "public"; // accessible everywhere
3 private b = "private"; // only in Base
4 protected c = "protected"; // Base + subclasses
5 readonly d = "readonly"; // no reassignment
6 #e = "es-private"; // true runtime private
7
8 demonstrate(): void {
9 console.log(this.a); // ok
10 console.log(this.b); // ok — same class
11 console.log(this.c); // ok — same class
12 console.log(this.d); // ok
13 console.log(this.#e); // ok — same class
14 }
15}
16
17class Derived extends Base {
18 test(): void {
19 console.log(this.a); // ok — public
20 // console.log(this.b); // Error: 'b' is private
21 console.log(this.c); // ok — protected accessible in subclass
22 // console.log(this.#e); // Error: '#e' is not accessible
23 }
24}
25
26const instance = new Derived();
27instance.a = "modified"; // ok
28// instance.b = "x"; // Error: 'b' is private
29// instance.c = "x"; // Error: 'c' is protected
30// instance.d = "x"; // Error: 'd' is readonly
31// instance.#e = "x"; // Syntax error — # fields use scoped access
32
33// Structural typing with private members
34class HasPrivate {
35 private secret = "hidden";
36}
37
38class LooksLikeHasPrivate {
39 private secret = "different";
40}
41
42// These are NOT structurally compatible despite identical shapes
43// let x: HasPrivate = new LooksLikeHasPrivate(); // Error!
44// TypeScript's private makes types nominal within the same class
45
46// protected in constructors — prevents direct instantiation
47class Singleton {
48 protected constructor() {}
49 static instance = new Singleton();
50}
51
52// class Extended extends Singleton { constructor() { super(); } } // ok
53// new Singleton(); // Error: constructor is protected

info

TypeScript's private creates nominal branding — two classes with identical private members are incompatible types. This is unique among TypeScript features, which is otherwise structurally typed.
protected vs private in Practice

The choice between protected and private defines your class extension contract. protected invites extension; private enforces encapsulation.

protected-vs-private.ts
TypeScript
1// private — strong encapsulation
2class Wallet {
3 private balance: number;
4
5 constructor(initialBalance: number) {
6 this.balance = initialBalance;
7 }
8
9 // Subclasses cannot access balance directly
10 // They must go through public methods
11 deposit(amount: number): void {
12 if (amount <= 0) throw new Error("Invalid amount");
13 this.balance += amount;
14 }
15
16 getBalance(): number {
17 return this.balance;
18 }
19}
20
21// protected — extension hook
22class ConfigStore {
23 protected config: Record<string, unknown> = {};
24
25 public get<T>(key: string): T | undefined {
26 return this.config[key] as T | undefined;
27 }
28
29 public set(key: string, value: unknown): void {
30 this.config[key] = value;
31 }
32}
33
34class ValidatedConfig extends ConfigStore {
35 set(key: string, value: unknown): void {
36 if (typeof value === "string" && value.length === 0) {
37 throw new Error("Empty string not allowed");
38 }
39 super.set(key, value); // can access protected config
40 }
41
42 getAll(): Record<string, unknown> {
43 return { ...this.config }; // protected — accessible in subclass
44 }
45}
46
47// When to use which:
48// - private: internal state that subclasses should never touch directly
49// - protected: hooks for subclasses (Template Method pattern)
50// - public: the API contract
51
52// Cross-instance private access — allowed!
53class Person {
54 private name: string;
55
56 constructor(name: string) {
57 this.name = name;
58 }
59
60 equals(other: Person): boolean {
61 return this.name === other.name; // can access other's private name
62 }
63}

best practice

Start with private. Promote to protected only when you have a concrete use case for a subclass to access the member. Unnecessary protected creates coupling that is hard to undo.
ES Private Fields (#)

JavaScript's native private fields (using #) provide true runtime privacy — they are enforced by the JavaScript engine, not just the type checker. They differ from TypeScript's private in several important ways.

es-private.ts
TypeScript
1class BankAccount {
2 // ES private field — truly private at runtime
3 #balance: number;
4
5 // TypeScript private — compile-time only, visible in JS
6 private tsSecret: string;
7
8 constructor(initialBalance: number) {
9 this.#balance = initialBalance;
10 this.tsSecret = "hidden";
11 }
12
13 deposit(amount: number): void {
14 if (amount <= 0) throw new Error("Invalid");
15 this.#balance += amount;
16 }
17
18 getBalance(): number {
19 return this.#balance;
20 }
21
22 // ES private methods — also supported!
23 #validateAmount(amount: number): boolean {
24 return amount > 0 && amount < 1000000;
25 }
26}
27
28const account = new BankAccount(1000);
29console.log(account.getBalance()); // 1000
30
31// At runtime:
32// account.#balance // SyntaxError — cannot access outside class
33// account.tsSecret // undefined — exists but TS warns
34
35// Key differences from TS private:
36// 1. # fields are truly private at runtime
37// 2. # fields use scoped lookup (faster)
38// 3. # fields cannot be accessed via bracket notation
39// 4. # fields are not inherited — subclass cannot access
40// 5. # fields can be used in the same class across instances
41
42class Subclass extends BankAccount {
43 test(): void {
44 // this.#balance // Error! # is scoped to BankAccount
45 // this.tsSecret // TS Error — but runtime access works!
46 }
47}
48
49// # fields with getters — controlled access
50class User {
51 #password: string;
52
53 constructor(password: string) {
54 this.#password = password;
55 }
56
57 checkPassword(attempt: string): boolean {
58 return this.#password === attempt;
59 }
60
61 // Exposing # through getter
62 get hasPassword(): boolean {
63 return this.#password.length > 0;
64 }
65}

info

Use ES # fields when you need true runtime privacy (library code, security-sensitive state). Use TypeScript private for intra-project API enforcement where the runtime access concern is minimal. They can coexist in the same class.
Parameter Properties Revisited

Parameter properties combine declaration and assignment in the constructor. They support all access modifiers, readonly, and ES private fields.

parameter-properties-revisited.ts
TypeScript
1class Service {
2 constructor(
3 // Public parameter property
4 public readonly name: string,
5 // Private with readonly
6 private readonly apiKey: string,
7 // Protected
8 protected baseUrl: string = "https://api.example.com",
9 // ES private — runtime private
10 #secretToken?: string
11 ) {
12 // No manual assignment needed for parameter properties
13 // # fields from constructor are also auto-assigned
14 }
15
16 getConfig(): { name: string; baseUrl: string } {
17 return { name: this.name, baseUrl: this.baseUrl };
18 }
19}
20
21const svc = new Service("my-service", "key-123", undefined, "token-abc");
22console.log(svc.name);
23// svc.apiKey — Error: private
24// svc.#secretToken — SyntaxError: ES private
25
26// Cannot mix parameter properties with this. assignments for same field
27class Valid {
28 constructor(public x: number) {} // parameter property — ok
29}
30
31class AlsoValid {
32 x: number;
33 constructor(x: number) {
34 this.x = x; // manual — also ok
35 }
36}
37
38// But NOT both:
39// class Conflicting {
40// constructor(public x: number) {
41// this.x = x; // Error: 'x' is already defined as a parameter property
42// }
43// }

best practice

Parameter properties are best when you have 5 or fewer constructor parameters. For complex constructors with many dependencies, consider using a builder pattern or a parameter object with a named type.
Decorators (TC39 Stage 3)

TypeScript 5.0 ships with the TC39 Stage 3 decorator proposal, replacing the older experimental decorators. These decorators work on classes, methods, accessors, properties, and parameters, enabling metaprogramming patterns with standard JavaScript semantics.

decorators.ts
TypeScript
1// Class decorator — wraps or replaces the constructor
2function logClass(target: Function, context: ClassDecoratorContext): void {
3 console.log(`Class defined: ${context.name}`);
4}
5
6@logClass
7class MyService {}
8
9// Method decorator — intercepts calls
10function logMethod<This, Args extends any[], Return>(
11 target: (this: This, ...args: Args) => Return,
12 context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
13) {
14 const methodName = String(context.name);
15
16 function replacement(this: This, ...args: Args): Return {
17 console.log(`${methodName} called with:`, args);
18 const result = target.call(this, ...args);
19 console.log(`${methodName} returned:`, result);
20 return result;
21 }
22
23 return replacement;
24}
25
26class Calculator {
27 @logMethod
28 add(a: number, b: number): number {
29 return a + b;
30 }
31
32 @logMethod
33 multiply(a: number, b: number): number {
34 return a * b;
35 }
36}
37
38const calc = new Calculator();
39calc.add(3, 4); // logs: add called with: [3, 4]; add returned: 7
40
41// Accessor decorator — wraps get/set
42function configurable(value: boolean) {
43 return function<This, Return>(
44 target: (this: This) => Return,
45 context: ClassGetterDecoratorContext<This, (this: This) => Return>
46 ): (this: This) => Return {
47 return function (this: This): Return {
48 console.log(`Accessing ${String(context.name)}`);
49 return target.call(this);
50 };
51 };
52}
53
54class Config {
55 #value = "secret";
56
57 @configurable(true)
58 get value(): string {
59 return this.#value;
60 }
61}
62
63// Auto-accessor decorator (Stage 3)
64class User {
65 @logChange
66 accessor name: string = "default";
67}
68
69function logChange<This, Value>(
70 target: ClassAccessorDecoratorTarget<This, Value>,
71 context: ClassAccessorDecoratorContext<This, Value>
72): ClassAccessorDecoratorTarget<This, Value> {
73 const { get, set } = target;
74
75 return {
76 get() {
77 return get.call(this);
78 },
79 set(newValue: Value) {
80 console.log(`Setting ${String(context.name)} to:`, newValue);
81 set.call(this, newValue);
82 },
83 };
84}

warning

TypeScript 5.0+ decorators use the TC39 Stage 3 proposal, which is different from the older experimental decorators (experimentalDecorators: true). Enable "useDefineForClassFields": true in your tsconfig when using native decorators.
Best Practices

1. Prefer abstract classes over implements for shared constructor logic and partial implementations. Use interfaces for pure contracts.

2. Default to private and open up to protected only when subclass access is required.

3. Use ES # fields for runtime-private state in library code. Use TS private for application code where compile-time enforcement is sufficient.

4. Abstract classes shine in framework and library design where you want to enforce a template method pattern.

5. Keep abstract classes shallow — one level of inheritance. Deep hierarchies are hard to maintain and understand.

6. Parameter properties are syntactic sugar — use them for simple constructors. For complex initialization, prefer a dedicated init method or builder.

7. Use the new TC39 decorators for cross-cutting concerns (logging, timing, validation). Avoid the deprecated experimentalDecorators option for new projects.

8. Remember that private in TypeScript creates nominal branding — two classes with the same private field name are incompatible types.

$Blueprint — Engineering Documentation·Section ID: TS-ABST·Revision: 1.0