Classes in Depth: Members, Modifiers, Statics, Abstracts, and Decorators

Master the full surface of TypeScript classes — constructors, access modifiers, parameter properties, accessors, static members and blocks, abstract classes, inheritance, true private fields, and a survey of modern class features like explicit resource management and decorators.

TypeScript classes compile to ordinary JavaScript classes but layer on compile-time type checking and access control JavaScript itself only partially provides. This lesson works through the complete feature set: the everyday features you'll use constantly, and the newer, more advanced ones worth knowing about even if you reach for them rarely.

Constructors, properties, and access modifiers

A class property can be declared with a type and optionally a default value, and must be assigned before construction finishes — either via a default or inside the constructor — under strictPropertyInitialization. public (the default), private, and protected modifiers control visibility: public everywhere, private only inside the declaring class, protected inside the class and its subclasses. Parameter properties (constructor(public id: number)) let you declare and assign a property in one step, removing the separate declaration-plus-this.x = x boilerplate.

readonly prevents reassignment after the constructor finishes (checked at compile time, not enforced at runtime), and combines freely with any access modifier — private readonly id: number is common for an identifier that's set once and never touched again by the class's own methods either.

Getters, setters, and static members

get/set accessor pairs let a property-like syntax (obj.value) run computed logic behind the scenes — useful for validation on write, or computing a derived value on read, without changing how calling code accesses the property. A get without a matching set effectively creates a readonly-from-the-outside computed property.

static members belong to the class itself, not to instances — accessed as ClassName.member rather than through an instance. They're used for shared counters, factory methods, and constants that belong conceptually to the class as a whole. A static { ... } block runs once, at class definition time, and is useful for more complex static initialization logic that a single expression can't express cleanly.

Inheritance, abstraction, and overriding

extends sets up single-class inheritance — a subclass inherits its parent's members and can override methods, calling super.method() to invoke the parent's version when needed. implements declares that a class satisfies one or more interface contracts, checked structurally at compile time; unlike extends, it contributes no runtime behavior at all.

abstract class declares a class that cannot be instantiated directly — only extended. abstract methods declare a signature with no implementation, forcing every concrete subclass to provide one. This is the standard way to express 'here's a shared shape and some shared behavior, but this one piece must be filled in differently by each subclass.'

True private fields, written with a # prefix (#balance), are a JavaScript language feature (not just a TypeScript compile-time check) — they're genuinely inaccessible from outside the class at runtime, not just flagged by the type checker. TypeScript's own private modifier is compile-time-only and erased at runtime; # fields are enforced by the JavaScript engine itself, even against code that bypasses the type checker entirely.

Class expressions and newer features

Classes can be written as expressions, not just declarations — const Base = class { ... }; — useful for defining a class conditionally, or returning one from a factory function. Class expressions support everything class declarations do, including extending another class expression or declaration.

'using' declarations and Explicit Resource Management (a newer TypeScript/JavaScript feature) let an object with a [Symbol.dispose]() method be automatically cleaned up at the end of its scope, similar to a try/finally written for you — useful for file handles, database connections, and similar resources that must always be released.

Decorators — functions that can observe or modify a class, method, accessor, property, or parameter at definition time, written with an @decoratorName syntax placed just above what they decorate — are an advanced, still-evolving feature. TypeScript now supports the standardized (Stage 3 ECMAScript) decorator proposal, which differs from TypeScript's original experimental decorators; the two are not fully interchangeable, and the standardized version includes decorator metadata support for runtime introspection. Treat decorators as an advanced topic: powerful for framework authors (dependency injection containers, ORMs, validation libraries) but rarely something application code needs to write from scratch.

A class touching most core features

Shape cannot be instantiated directly (new Shape(...) is an error) because it's abstract and declares an abstract method area(). #id is a true private field, inaccessible even from Circle despite Circle extending Shape — # privacy doesn't extend to subclasses the way protected does. Circle must implement area() because Shape declared it abstract without a body.

abstract class Shape {
  static count = 0;

  static {
    console.log("Shape class initialized");
  }

  #id: number;

  constructor(public readonly name: string) {
    this.#id = ++Shape.count;
  }

  get id(): number {
    return this.#id;
  }

  abstract area(): number;

  describe(): string {
    return `${this.name} #${this.id}: area=${this.area()}`;
  }
}

class Circle extends Shape {
  constructor(name: string, private radius: number) {
    super(name);
  }

  area(): number {
    return Math.PI * this.radius ** 2;
  }
}

Code Examples

Both id and name become public readable/writable properties automatically because of the parameter property shorthand — no manual this.id = id; needed. Expected result: 1 Ada

celsius looks like a plain property from calling code (t.celsius = -300) but actually runs validation logic through the set accessor; fahrenheit is a read-only computed property backed entirely by a get with no matching set.

Common mistake: Exposing _celsius directly as a public property instead of going through accessors would let any code set an invalid temperature without validation ever running.

class User {
  constructor(
    public id: number,
    public name: string
  ) {}
}

const u = new User(1, "Ada");
console.log(u.id, u.name);
class Temperature {
  private _celsius: number;

  constructor(celsius: number) {
    this._celsius = celsius;
  }

  get celsius(): number {
    return this._celsius;
  }

  set celsius(value: number) {
    if (value < -273.15) {
      throw new Error("Below absolute zero");
    }
    this._celsius = value;
  }

  get fahrenheit(): number {
    return this._celsius * 9 / 5 + 32;
  }
}

const t = new Temperature(25);
console.log(t.fahrenheit); // 77
t.celsius = -300;          // throws: Below absolute zero

Access modifier and privacy comparison

TypeScript's private is a compile-time promise; # fields are a JavaScript-level guarantee enforced by the engine itself.

public    -> visible everywhere
protected -> visible in this class + subclasses
private   -> visible only inside this class (compile-time only)
#field    -> visible only inside this class (enforced at runtime too,
             not even reachable via subclasses or type-check bypass)

Important Rules

Default to private (or # for true runtime privacy) for internal state; widen access only when something outside genuinely needs it.

abstract classes can't be instantiated directly — use them to share structure and partial behavior across subclasses while forcing each subclass to supply the pieces that must vary.

implements checks that a class satisfies an interface's shape at compile time only — it adds no runtime behavior, unlike extends.

Prefer TypeScript's private for everyday encapsulation (simpler, works fine with tooling); reach for # fields specifically when you need privacy that holds even against code that bypasses type checking (e.g., after compiling to plain JS, or via casts).

Treat decorators as an advanced, framework-level feature — know what @decorator syntax means when you read it, but most application code doesn't need to author decorators from scratch.

Where this shows up

Domain model classes — User, Order, Account — in a typical backend or frontend service layer almost always use parameter properties and private/# state to enforce that fields like balances or identifiers can only change through controlled methods, not direct external assignment. Abstract base classes commonly define a shared template method (like describe() above) that calls an abstract method each subclass must implement differently.

class User { id: number; name: string; constructor(id, name) { this.id = id; this.name = name; } } class User { constructor(public id: number, public name: string) {} }

Leaving constructor parameters unannotated makes them implicitly any, and manually re-declaring properties that parameter properties would create automatically is unnecessary repetition.

[multiple-choice] What does constructor(public id: number) do that constructor(id: number) does not? A. Nothing, they're identical B. It automatically declares and assigns a public class property named id C. It makes id readonly D. It makes id optional

B. It automatically declares and assigns a public class property named id — This is the parameter property shorthand — it declares a property and assigns the constructor argument to it in one step.

[multiple-choice] How does a # private field differ from TypeScript's private modifier? A. They're identical in every way B. # fields are enforced by the JavaScript runtime itself, not just the type checker, so they stay private even past compilation or a type-check bypass C. private is enforced at runtime and # fields are not D. # fields can be accessed by subclasses, private cannot

B. # fields are enforced by the JavaScript runtime itself, not just the type checker, so they stay private even past compilation or a type-check bypass — TypeScript's private is a compile-time-only restriction, erased in the compiled output; # fields are a genuine JavaScript language feature enforced by the engine, so they remain inaccessible even to code that works around the type checker.

[true-false] A class that implements an interface automatically inherits runtime behavior (default method implementations) from that interface. A. True B. False

B. False — implements is a compile-time structural check only — interfaces carry no runtime code at all, so the implementing class must provide every member's actual implementation itself.

Write an abstract class Employee with a protected readonly name: string set via the constructor, an abstract monthlySalary(): number method, and a concrete summary(): string method that returns a string using this.name and this.monthlySalary(). Write a Developer subclass with a private baseSalary: number and a monthlySalary() implementation that returns it.

abstract class Employee { constructor(protected readonly name: string) {} abstract monthlySalary(): number; summary(): string { return ${this.name} earns ${this.monthlySalary()}/mo; } } class Developer extends Employee { constructor(name: string, private baseSalary: number) { super(name); } monthlySalary(): number { return this.baseSalary; } }

The subclass constructor must call super(name) before using this.