Classes, modifiers and abstract types

Access modifiers and parameter properties, runtime-private fields, implements versus extends, abstract members, and the static side of a class.

Modifiers and parameter properties

class Invoice {
  // parameter properties declare and assign in one line
  constructor(
    public readonly id: string,
    private items: number[] = [],
    protected currency: string = 'USD'
  ) {}

  #revision = 0;                  // a real runtime-private field

  add(amount: number): void {
    this.items.push(amount);
    this.#revision++;
  }

  get total(): number {
    return this.items.reduce((sum, value) => sum + value, 0);
  }

  format(): string {
    return this.currency + ' ' + this.total.toFixed(2);
  }
}

const invoice = new Invoice('INV-1');
invoice.add(19.5);
// invoice.items;                // error: private
// invoice.id = 'INV-2';         // error: readonly
ModifierWho can access itRuntime effect
public (default)EveryoneNone
protectedThe class and its subclassesNone - erased
privateThe declaring class onlyNone - erased
#fieldThe declaring class onlyEnforced by the JavaScript engine
readonlyEveryone may readNone - compile-time only
staticThe class itselfA property on the constructor
abstractSubclasses must implementNo member is emitted

implements versus extends

abstract class Shape {
  abstract area(): number;            // no body: subclasses must provide one

  describe(): string {                // shared implementation lives here
    return this.constructor.name + ' ' + this.area().toFixed(2);
  }
}

class Circle extends Shape {
  constructor(private radius: number) {
    super();
  }
  area(): number { return Math.PI * this.radius ** 2; }
}

interface Serializable {
  toJSON(): unknown;
}

// implements checks the instance side only - no code is inherited
class Config implements Serializable {
  constructor(readonly path: string) {}
  toJSON(): unknown { return { path: this.path }; }
}

class SecureConfig extends Config implements Serializable {
  override toJSON(): unknown { return { path: this.path, secure: true }; }
}

// a constructor type is written separately: interfaces describe instances
type ConfigCtor = new (path: string) => Config;
function build(Ctor: ConfigCtor, path: string): Config {
  return new Ctor(path);
}
  • extends inherits implementation and requires super() before this is used; implements only checks that the shape matches.
  • Turn on noImplicitOverride so an overriding method must be marked override - that is what catches a renamed base method.
  • A subclass may narrow a return type, but its parameter types must stay compatible with the base.
  • Prefer an interface when unrelated classes share a role and no code is reused; use an abstract class when the base holds real state or a constructor contract.

The static side and decorators

class Registry<T> {
  static instances = 0;                    // static members live on the constructor

  static create<T>(value: T): Registry<T> {
    return new Registry(value);
  }

  constructor(public value: T) {
    Registry.instances++;
  }
}

// the static side is not polymorphic: a subclass reading
// this.instances gets its own property, not the base class's
class SubRegistry<T> extends Registry<T> {}

// a this-predicate narrows the receiver inside a class hierarchy
class Base {
  isDerived(): this is Derived { return this instanceof Derived; }
}
class Derived extends Base {
  extra = true;
}
function use(value: Base): string {
  return value.isDerived() ? String(value.extra) : 'base';
}

// a class decorator receives the constructor and may replace it
type Ctor<T = object> = new (...args: any[]) => T;
function tagged<T extends Ctor>(ctor: T): T & Ctor<{ tag: string }> {
  const Base2 = ctor as Ctor;
  return class extends Base2 {
    tag = 'tagged';
  } as T & Ctor<{ tag: string }>;
}

@tagged
class Service {}
⚠️
private and readonly are erased when the code is compiled, so they stop mistakes in your own code but do not protect data from a caller at runtime. Treat them as documentation, and use #fields when real encapsulation is the point.

FAQ

private or #private?
private is a compile-time convention that other code can still reach through a cast; # is enforced by the JavaScript engine and cannot be bypassed. Use private for design intent and # for values that must genuinely stay internal.
When should a class be an interface instead?
When there is no state and no shared implementation. An interface plus a factory function avoids the inheritance chain and is easier to test, because a caller can pass any matching object.

Generic patterns without over-engineering Real-world modelling: errors, API and state

Last refreshed 2026-09-18.