TypeScript — Abstract & Access Modifiers
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
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.
| 1 | abstract 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 | |
| 23 | class 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 | |
| 42 | class 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 | |
| 59 | const shapes: Shape[] = [new Circle("c1", 5), new Square("s1", 4)]; |
| 60 | shapes.forEach((s) => console.log(s.describe())); |
| 61 | // Shape[c1]: area=78.54 |
| 62 | // Shape[s1]: area=16.00 |
best practice
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.
| 1 | abstract 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 | |
| 25 | class 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 |
| 51 | abstract 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
Beyond basic visibility, TypeScript's access modifiers interact with inheritance, structural typing, and declaration files in nuanced ways. Understanding these interactions prevents subtle bugs.
| 1 | class 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 | |
| 17 | class 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 | |
| 26 | const instance = new Derived(); |
| 27 | instance.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 |
| 34 | class HasPrivate { |
| 35 | private secret = "hidden"; |
| 36 | } |
| 37 | |
| 38 | class 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 |
| 47 | class 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
The choice between protected and private defines your class extension contract. protected invites extension; private enforces encapsulation.
| 1 | // private — strong encapsulation |
| 2 | class 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 |
| 22 | class 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 | |
| 34 | class 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! |
| 53 | class 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
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.
| 1 | class 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 | |
| 28 | const account = new BankAccount(1000); |
| 29 | console.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 | |
| 42 | class 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 |
| 50 | class 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
Parameter properties combine declaration and assignment in the constructor. They support all access modifiers, readonly, and ES private fields.
| 1 | class 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 | |
| 21 | const svc = new Service("my-service", "key-123", undefined, "token-abc"); |
| 22 | console.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 |
| 27 | class Valid { |
| 28 | constructor(public x: number) {} // parameter property — ok |
| 29 | } |
| 30 | |
| 31 | class 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
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.
| 1 | // Class decorator — wraps or replaces the constructor |
| 2 | function logClass(target: Function, context: ClassDecoratorContext): void { |
| 3 | console.log(`Class defined: ${context.name}`); |
| 4 | } |
| 5 | |
| 6 | @logClass |
| 7 | class MyService {} |
| 8 | |
| 9 | // Method decorator — intercepts calls |
| 10 | function 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 | |
| 26 | class 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 | |
| 38 | const calc = new Calculator(); |
| 39 | calc.add(3, 4); // logs: add called with: [3, 4]; add returned: 7 |
| 40 | |
| 41 | // Accessor decorator — wraps get/set |
| 42 | function 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 | |
| 54 | class 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) |
| 64 | class User { |
| 65 | @logChange |
| 66 | accessor name: string = "default"; |
| 67 | } |
| 68 | |
| 69 | function 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
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.