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| Modifier | Who can access it | Runtime effect |
|---|---|---|
public (default) | Everyone | None |
protected | The class and its subclasses | None - erased |
private | The declaring class only | None - erased |
#field | The declaring class only | Enforced by the JavaScript engine |
readonly | Everyone may read | None - compile-time only |
static | The class itself | A property on the constructor |
abstract | Subclasses must implement | No 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);
}extendsinherits implementation and requiressuper()beforethisis used;implementsonly checks that the shape matches.- Turn on
noImplicitOverrideso an overriding method must be markedoverride- 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.
Related
Generic patterns without over-engineering Real-world modelling: errors, API and state
Last refreshed 2026-09-18.