Skip to main content

Classes & OOP

TypeScript adds powerful features to JavaScript classes: access modifiers, abstract classes, and more. It makes object-oriented programming feel natural and type-safe. While JavaScript classes are essentially syntactic sugar over prototypal inheritance, TypeScript transforms them into proper OOP constructs with compile-time visibility enforcement, abstract contracts, and type-safe generics. If you come from Java or C#, TypeScript classes will feel familiar. If you come from pure JavaScript, they will feel like a major upgrade in safety and expressiveness.

1. Class Basics

Basic Class Structure

Parameter Properties (Shorthand)

TypeScript can automatically create and assign properties from constructor parameters. This is one of the most loved TypeScript features because it eliminates the tedious boilerplate of declaring a property, adding a constructor parameter, and assigning one to the other:

2. Access Modifiers

Control visibility of class members. Access modifiers are the compiler-enforced equivalent of “do not touch” signs. They tell other developers (and the compiler) which parts of a class are part of its public API and which are internal implementation details. This matters enormously for maintainability: if a property is private, you can change or remove it without worrying about breaking consumers.

public (Default)

Accessible from anywhere. This is the default in TypeScript, just like JavaScript.

private

Only accessible within the class.

protected

Accessible within the class and subclasses.

Private Fields (ES2022)

True private fields using # syntax.
private vs #: private is TypeScript-only (compile-time). # is JavaScript-native (runtime-enforced). Use # for true encapsulation.

3. Readonly Properties

Properties that can only be set in the constructor.

Combining with Parameter Properties


4. Getters and Setters

Define computed properties with validation.

5. Static Members

Properties and methods on the class itself, not instances.

Singleton Pattern


6. Inheritance

Extend classes to create specialized versions.

Method Overriding with override


7. Abstract Classes

Define templates for subclasses. Cannot be instantiated directly. Abstract classes are a “contract with benefits” — like an interface, they define what subclasses must implement, but unlike an interface, they can also provide shared implementation code. This is the sweet spot between “pure interface” (no shared code) and “concrete base class” (forces a specific implementation).

8. Implementing Interfaces

Classes can implement one or more interfaces. The implements keyword is a compile-time contract: it guarantees that the class provides every property and method the interface requires. If you add a new method to the interface, every implementing class that is missing it immediately gets a compiler error. This is one of the most powerful patterns for enforcing consistency across a codebase.

Interface vs Abstract Class


9. Generic Classes

Classes that work with multiple types.

Generic Constraints in Classes


10. Decorators

Decorators are a way to add metadata or modify class behavior declaratively using @ syntax. They are heavily used in frameworks like NestJS, Angular, and TypeORM. TypeScript has had experimental support for years, and the TC39 proposal recently reached Stage 3, meaning the syntax is stabilizing. Decorators shine when you want to add cross-cutting concerns (logging, validation, caching, authorization) without polluting business logic.
Enable decorators in tsconfig.json:

Class Decorator

Decorator Factory

Method Decorator

Property Decorator


11. Practical Example: Service Layer


Summary

Next, we’ll dive deep into advanced types!

Interview Deep-Dive

Strong Answer:This question tests whether you understand the difference between compile-time and runtime enforcement, which is a fundamental TypeScript concept.
  • private keyword (TypeScript-only): Enforced only at compile time. The compiled JavaScript has no concept of private — the property is a regular public property on the object. If someone casts to any or accesses the object from plain JavaScript, the “private” field is fully accessible. (account as any).balance bypasses the protection entirely. However, private integrates naturally with TypeScript’s type system, works with access modifier parameter properties (constructor(private balance: number)), and produces clear error messages.
  • # private fields (JavaScript-native): Enforced at runtime by the JavaScript engine. #balance is truly inaccessible from outside the class — no cast, no escape hatch, no workaround. account.#balance is a syntax error even in plain JavaScript. The engine uses a WeakMap-like mechanism internally to make the field genuinely unreachable.
  • When to choose private: When you are writing a fully TypeScript codebase where every consumer goes through the type system. It is simpler syntax, integrates with parameter properties, and the IDE experience is slightly better (autocomplete correctly hides private members). Most application code falls here.
  • When to choose #: When you are writing a library consumed by JavaScript users, when you need security guarantees (the field cannot be accessed even by malicious or buggy code), or when the object will be serialized/deserialized and you need the private field to survive the round trip without appearing in JSON.stringify() output. # fields are excluded from for...in, Object.keys(), and JSON.stringify().
  • The gotcha with #: Private fields are per-class, not per-instance. A method in class BankAccount can access other.#balance if other is also a BankAccount instance. This is consistent with how private fields work in Java and C++ but surprises JavaScript developers.
Follow-up: Can you use # private fields with TypeScript’s parameter properties shorthand?No. constructor(#balance: number) is a syntax error. Parameter properties only work with TypeScript’s access modifiers (public, private, protected, readonly). If you want # private fields, you must declare them explicitly in the class body and assign in the constructor. This is one of the practical friction points that keeps many teams on private even though # is technically superior for encapsulation.
Strong Answer:Abstract classes and interfaces are both about defining contracts, but they serve different architectural purposes and have different technical implications.
  • Abstract classes provide implementation sharing: An abstract class can have concrete methods with real code that subclasses inherit. abstract class Shape { describe(): string { return 'A ' + this.color + ' shape'; } abstract getArea(): number; }describe() has a shared implementation, getArea() must be implemented by each subclass. Interfaces cannot provide any implementation.
  • Interfaces support multiple inheritance: A class can implement multiple interfaces (class Doc implements Printable, Serializable, Loggable), but it can only extend one abstract class. If you need to compose behaviors from multiple sources, interfaces are the only option.
  • Abstract classes have runtime presence: They compile to JavaScript classes. You can use instanceof on them: if (shape instanceof Shape) works. Interfaces are erased — they do not exist at runtime. You cannot instanceof check an interface.
  • Abstract classes carry constructor constraints: If abstract class Repository has a constructor that takes a DatabaseConnection, every subclass must call super(connection). This enforces initialization requirements. Interfaces have no constructors.
  • My decision framework: Use an abstract class when you have shared implementation code and a clear “is-a” hierarchy (a Circle IS a Shape). Use an interface when you are defining a capability contract (“can be printed,” “can be serialized”) that unrelated classes might independently satisfy. In practice, lean toward interfaces for dependency injection boundaries (your service depends on interface UserRepository, not abstract class BaseRepository) because interfaces are lighter and more flexible for testing.
Follow-up: Can you use both together? When would that make sense?Yes, and this is a common pattern in well-designed TypeScript applications. Define an interface for the contract (interface Repository<T> { find, create, update, delete }), then create an abstract class that partially implements it (abstract class BaseRepository<T> implements Repository<T> with shared logic for caching or logging), and finally concrete classes that extend the abstract class (class UserRepository extends BaseRepository<User>). Consuming code depends on the interface, enabling mock implementations for testing. The abstract class provides DRY implementation for production classes. This layered approach gives you the best of both worlds: clean contracts at the boundary and shared implementation internally.
Strong Answer:Decorators are a metaprogramming feature that lets you modify classes, methods, and properties at definition time. They are used extensively in frameworks like Angular, NestJS, and TypeORM.
  • How they work: A decorator is a function that receives the thing it decorates (a class constructor, a method descriptor, or a property descriptor) and can modify or replace it. @Logger class User {} calls Logger(User) at class definition time. A method decorator like @Log add(a, b) receives the target object, the property name, and the property descriptor, and can wrap the original method with additional behavior (logging, validation, caching).
  • Practical uses: Dependency injection (Angular’s @Injectable()), route definition (NestJS’s @Get('/users')), ORM column mapping (TypeORM’s @Column()), validation (@Required, @MinLength(3)), and cross-cutting concerns like logging, caching, and rate limiting. Decorators separate infrastructure concerns from business logic.
  • The status complication: TypeScript has two decorator implementations. The “legacy” implementation (experimentalDecorators: true in tsconfig) follows an older TC39 proposal and is what Angular and NestJS use today. The “modern” implementation (TypeScript 5.0+) follows the TC39 Stage 3 proposal with different semantics (decorators receive a context object instead of the three-argument target, propertyKey, descriptor). These two are not compatible. If you are starting a new project without a framework mandate, use the Stage 3 decorators. If you use Angular or NestJS, you must use the legacy decorators until those frameworks migrate.
  • The gotcha: Decorator factories (decorators that take arguments) return the actual decorator function. @Logger('INFO') first calls Logger('INFO'), which returns a function, and that function is then called with the class constructor. Missing the factory pattern is a common bug: @Logger('INFO') works, but @Logger (without arguments when it expects them) passes the class constructor as the first argument instead of the prefix string, producing confusing behavior.
Follow-up: How would you implement a simple caching decorator for a class method?You would write a method decorator that replaces the original method with a wrapper. The wrapper checks a Map keyed by the serialized arguments. If the key exists, return the cached value. If not, call the original method, store the result, and return it. The decorator has access to descriptor.value (the original function) and replaces it: descriptor.value = function(...args) { const key = JSON.stringify(args); if (cache.has(key)) return cache.get(key); const result = original.apply(this, args); cache.set(key, result); return result; }. The this context must be forwarded with apply, not lost through a bare function call. The cache lifetime should be considered — a naive implementation leaks memory. In production, you would add TTL expiration or LRU eviction.
Strong Answer:The TypeScript Singleton uses private constructor() to prevent external instantiation and a static method to control access to the single instance.
  • Implementation: private constructor() makes new Database() a compile error outside the class. static getInstance() lazily creates the instance on first call and returns the cached instance on subsequent calls. private static instance: Database holds the reference. All consumers call Database.getInstance() and receive the same object.
  • When it is appropriate: Database connection pools, configuration managers, and logger instances — resources that are expensive to create, shared across the application, and where having multiple instances would cause problems (multiple connection pools exhausting database connections, conflicting configuration states). In JavaScript specifically, module-level singletons are common because Node.js caches modules — export const db = new Database() creates a singleton implicitly because the module is only loaded once.
  • When it is harmful: When it creates hidden dependencies and makes testing difficult. If UserService.getInstance() internally calls Database.getInstance(), you cannot test UserService without a real database. The Singleton hides the dependency — it is not in the constructor signature, so you cannot inject a mock. This is the primary argument against Singletons in modern architecture.
  • The modern alternative: Dependency injection. Instead of UserService reaching for Database.getInstance(), the constructor takes Database as a parameter: constructor(private db: Database). In production, a DI container provides the real database. In tests, you inject a mock. The “single instance” guarantee moves from the class itself to the DI container’s configuration (register as singleton scope). This gives you the same runtime behavior with full testability.
Follow-up: Does the private constructor pattern work at runtime in JavaScript, or is it just a TypeScript compile-time check?private is TypeScript-only and erased at runtime. In the compiled JavaScript, the constructor is a regular public constructor, and new Database() works fine from JavaScript code. To enforce the Singleton at runtime, you would need to check inside the constructor: if (Database.instance) throw new Error('Use getInstance()'), or use # private fields (which have runtime enforcement). This is another case where TypeScript’s compile-time guarantees do not automatically translate to runtime safety — important if your class is consumed by JavaScript code or if you are concerned about reflection-based access.