Classes and OOP
TypeScript adds substantial OOP machinery to JavaScript’s prototype-based class system: access modifiers (public, private, protected), readonly properties, abstract classes, interfaces, parameter properties (compact constructor declarations), getters and setters, static members, and generic classes. The class system is single-inheritance — a class extends at most one other class — but admits implementing multiple interfaces. The principal contemporary discipline favours composition and interfaces over deep inheritance hierarchies; classes are conventional for stateful entities, framework-required base classes, and substantial encapsulation.
Class declarations
The principal form:
class Counter {
count: number;
constructor(initial: number = 0) {
this.count = initial;
}
increment(): void {
this.count++;
}
get(): number {
return this.count;
}
}
const c = new Counter();
c.increment();
console.log(c.get()); // 1
The constructor is the special initialiser; this refers to the instance. Methods are declared without function.
Properties and initialisation
Properties may be declared with explicit types and initialisers:
class User {
name: string = "";
age: number = 0;
email?: string; // optional
readonly id: string; // immutable
constructor(id: string) {
this.id = id;
}
}
Under strictPropertyInitialization: true (part of strict), every non-optional property must be initialised — either at declaration, in the constructor, or marked with ! (definite assignment assertion):
class User {
name!: string; // assert it will be initialised elsewhere
// ... e.g., by a framework
}
The conventional discipline initialises in the constructor.
Parameter properties
A compact form: declare and assign in the constructor signature:
class Point {
constructor(public x: number, public y: number) {}
}
const p = new Point(1, 2);
console.log(p.x, p.y); // 1 2
The public x: number declares a public property x and assigns the constructor argument to it. The form admits substantial conciseness for data classes; the access modifier (public, private, protected, readonly) is required for parameter properties.
Access modifiers
class User {
public name: string; // visible everywhere
private password: string; // visible only within User
protected role: string; // visible in User and subclasses
readonly id: string; // visible everywhere; immutable
constructor(name: string, password: string, role: string, id: string) {
this.name = name;
this.password = password;
this.role = role;
this.id = id;
}
}
const u = new User("Alice", "pwd", "admin", "u-1");
u.name; // OK
// u.password; // ERROR: private
// u.role; // ERROR: protected
u.id; // OK
// u.id = "u-2"; // ERROR: readonly
The modifiers are compile-time checks — they are erased during emission. The runtime JavaScript admits accessing password directly. For runtime privacy, the JavaScript-native # private fields are conventional.
JavaScript-native private fields
Since ES2022, the # prefix admits truly private fields:
class User {
#password: string; // truly private at runtime
constructor(password: string) {
this.#password = password;
}
verify(input: string): boolean {
return input === this.#password;
}
}
const u = new User("secret");
// u.#password; // ERROR (and at runtime)
The # form admits runtime privacy; the TypeScript private is compile-time only. The conventional contemporary discipline uses # for genuinely private fields and private for the conventional access-control discipline.
Inheritance
The extends keyword admits inheriting from a parent class:
class Animal {
constructor(public name: string) {}
speak(): string {
return "...";
}
}
class Dog extends Animal {
constructor(name: string, public breed: string) {
super(name); // call parent constructor
}
override speak(): string { // explicitly override
return `${this.name} says woof`;
}
fetch(): void {
console.log(`${this.name} fetches the ball`);
}
}
const d = new Dog("Rex", "Labrador");
console.log(d.speak()); // "Rex says woof"
d.fetch();
The super(...) call invokes the parent constructor; it must precede any access to this.
The override keyword (since TS 4.3) is conventional — under noImplicitOverride: true, it is required for methods that override a parent method.
Abstract classes
The abstract keyword introduces classes that cannot be instantiated directly:
abstract class Shape {
abstract area(): number; // abstract method (no body)
describe(): string { // concrete method
return `Shape with area ${this.area()}`;
}
}
class Circle extends Shape {
constructor(public radius: number) { super(); }
area(): number {
return Math.PI * this.radius ** 2;
}
}
// const s = new Shape(); // ERROR: cannot instantiate abstract
const c = new Circle(5);
console.log(c.describe()); // "Shape with area 78.5..."
Subclasses must implement all abstract members; the form admits enforcing a contract.
Interfaces
Interfaces declare types — they exist at the type level only:
interface Describable {
describe(): string;
}
interface Serializable {
serialize(): string;
}
class User implements Describable, Serializable {
constructor(public name: string, public age: number) {}
describe(): string {
return `${this.name} (${this.age})`;
}
serialize(): string {
return JSON.stringify({ name: this.name, age: this.age });
}
}
implements checks that the class satisfies the interface; multiple interfaces admit multiple-contract conformance.
The conventional contemporary discipline:
- Use interfaces for public contracts and shared shapes.
- Use abstract classes when shared implementation is needed (not just contract).
- Prefer composition over inheritance.
Getters and setters
The get and set keywords admit accessor methods:
class Temperature {
private _celsius: number = 0;
get celsius(): number {
return this._celsius;
}
set celsius(value: number) {
if (value < -273.15) throw new RangeError("below absolute zero");
this._celsius = value;
}
get fahrenheit(): number {
return this._celsius * 9 / 5 + 32;
}
set fahrenheit(value: number) {
this._celsius = (value - 32) * 5 / 9;
}
}
const t = new Temperature();
t.celsius = 25;
console.log(t.fahrenheit); // 77
t.fahrenheit = 100;
console.log(t.celsius); // 37.78
Getters and setters admit “looks like a property, runs code”. The conventional discipline uses them for derived properties and validation.
static members
The static keyword introduces class-level (not instance-level) members:
class MathUtils {
static readonly PI = 3.14159;
static circleArea(radius: number): number {
return MathUtils.PI * radius ** 2;
}
}
console.log(MathUtils.PI);
console.log(MathUtils.circleArea(5));
Static members are accessed via the class name, not an instance.
Since ES2022, static blocks admit substantial initialisation:
class Config {
static readonly settings: Record<string, string> = {};
static {
for (const key of Object.keys(process.env)) {
if (key.startsWith("APP_")) {
Config.settings[key.slice(4).toLowerCase()] = process.env[key]!;
}
}
}
}
Generic classes
Type parameters admit generic classes:
class Stack<T> {
private items: T[] = [];
push(x: T): void {
this.items.push(x);
}
pop(): T | undefined {
return this.items.pop();
}
peek(): T | undefined {
return this.items[this.items.length - 1];
}
}
const s = new Stack<number>();
s.push(1);
s.push(2);
console.log(s.pop()); // 2
Treated in Generics.
this types
The this type admits referring to the current class:
class Builder {
private parts: string[] = [];
add(part: string): this { // returns the same type as the receiver
this.parts.push(part);
return this;
}
build(): string {
return this.parts.join(" ");
}
}
class QueryBuilder extends Builder {
select(field: string): this {
return this.add(`SELECT ${field}`);
}
}
const qb = new QueryBuilder();
qb.select("name")
.add("FROM users")
.build();
The this return type admits method chaining that respects subtyping — select on a QueryBuilder returns QueryBuilder, not Builder.
Class type vs instance type
A class produces both a constructor type and an instance type:
class User {
constructor(public name: string) {}
}
type UserInstance = User; // the instance type
type UserConstructor = typeof User; // the constructor type
function createUser(C: UserConstructor, name: string): UserInstance {
return new C(name);
}
The pattern admits factory-style construction.
Mixins
Mixins admit composition through subclassing:
type Constructor<T = {}> = new (...args: any[]) => T;
function Timestamped<TBase extends Constructor>(Base: TBase) {
return class extends Base {
timestamp = Date.now();
};
}
function Tagged<TBase extends Constructor>(Base: TBase) {
return class extends Base {
tags: string[] = [];
addTag(tag: string): void {
this.tags.push(tag);
}
};
}
class Event {
constructor(public name: string) {}
}
class TaggedTimestampedEvent extends Tagged(Timestamped(Event)) {}
const e = new TaggedTimestampedEvent("click");
e.addTag("important");
console.log(e.timestamp, e.tags);
The pattern admits substantial composition without single-inheritance constraints; conventional in libraries that need flexible composition.
Common patterns
Data class with parameter properties
class User {
constructor(
public readonly id: string,
public name: string,
public email: string,
public age: number,
) {}
rename(newName: string): User {
return new User(this.id, newName, this.email, this.age);
}
}
Validation in constructor
class Email {
constructor(public readonly address: string) {
if (!Email.isValid(address)) {
throw new Error(`Invalid email: ${address}`);
}
}
static isValid(s: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s);
}
}
Private factory pattern
class User {
private constructor(
public readonly id: string,
public name: string,
) {}
static create(name: string): User {
return new User(crypto.randomUUID(), name);
}
}
const u = User.create("Alice");
// const u = new User(...); // ERROR: constructor is private
Singleton
class Logger {
private static instance: Logger | null = null;
private constructor() {}
static getInstance(): Logger {
if (Logger.instance === null) {
Logger.instance = new Logger();
}
return Logger.instance;
}
log(message: string): void {
console.log(`[${new Date().toISOString()}] ${message}`);
}
}
const logger = Logger.getInstance();
Builder pattern
class HttpRequestBuilder {
private url = "";
private method = "GET";
private headers: Record<string, string> = {};
private body?: string;
withUrl(url: string): this {
this.url = url;
return this;
}
withMethod(method: string): this {
this.method = method;
return this;
}
withHeader(key: string, value: string): this {
this.headers[key] = value;
return this;
}
withBody(body: string): this {
this.body = body;
return this;
}
build(): { url: string; method: string; headers: Record<string, string>; body?: string } {
return {
url: this.url,
method: this.method,
headers: this.headers,
body: this.body,
};
}
}
const req = new HttpRequestBuilder()
.withUrl("/api/users")
.withMethod("POST")
.withHeader("Content-Type", "application/json")
.withBody('{"name":"alice"}')
.build();
Strategy pattern
interface SortStrategy<T> {
sort(items: T[]): T[];
}
class QuickSort<T> implements SortStrategy<T> {
constructor(private compare: (a: T, b: T) => number) {}
sort(items: T[]): T[] { /* ... */ return items; }
}
class MergeSort<T> implements SortStrategy<T> {
constructor(private compare: (a: T, b: T) => number) {}
sort(items: T[]): T[] { /* ... */ return items; }
}
class Sorter<T> {
constructor(private strategy: SortStrategy<T>) {}
sort(items: T[]): T[] {
return this.strategy.sort(items);
}
}
const s = new Sorter(new QuickSort<number>((a, b) => a - b));
Observer pattern
type Listener<E> = (event: E) => void;
class EventBus<E> {
private listeners: Listener<E>[] = [];
on(listener: Listener<E>): () => void {
this.listeners.push(listener);
return () => {
const i = this.listeners.indexOf(listener);
if (i >= 0) this.listeners.splice(i, 1);
};
}
emit(event: E): void {
for (const listener of this.listeners) {
listener(event);
}
}
}
const bus = new EventBus<{ type: string; data: unknown }>();
const off = bus.on(e => console.log(e.type, e.data));
bus.emit({ type: "click", data: { x: 1, y: 2 } });
off(); // unsubscribe
Abstract base class
abstract class Repository<T extends { id: string }> {
abstract find(id: string): Promise<T | null>;
abstract save(item: T): Promise<void>;
async exists(id: string): Promise<boolean> {
return (await this.find(id)) !== null;
}
}
class UserRepository extends Repository<User> {
async find(id: string): Promise<User | null> {
// ...
return null;
}
async save(user: User): Promise<void> {
// ...
}
}
Discriminated union via class hierarchy
abstract class Shape {
abstract readonly kind: string;
abstract area(): number;
}
class Circle extends Shape {
readonly kind = "circle" as const;
constructor(public radius: number) { super(); }
area(): number { return Math.PI * this.radius ** 2; }
}
class Square extends Shape {
readonly kind = "square" as const;
constructor(public side: number) { super(); }
area(): number { return this.side ** 2; }
}
function describe(s: Shape): string {
switch (s.kind) {
case "circle": return `Circle r=${(s as Circle).radius}`;
case "square": return `Square s=${(s as Square).side}`;
default: return "unknown";
}
}
The conventional contemporary alternative is plain discriminated unions (treated in Narrowing).
A note on the conventional discipline
The contemporary TypeScript class advice:
- Use classes for stateful entities and framework-required base classes.
- Prefer composition over inheritance.
- Use interfaces for public contracts.
- Use abstract classes when shared implementation is needed.
- Use parameter properties for compact data classes.
- Use
readonlyfor immutable properties. - Use
#for runtime privacy;privatefor compile-time access control. - Use
override(undernoImplicitOverride) for clear intent. - Use
staticfor class-level constants and factory methods. - Use generics for genuinely generic classes.
- Use
thisreturn types for method chaining.
The combination — classes with access modifiers, abstract classes, interfaces, parameter properties, getters/setters, generic classes, mixins via higher-order class functions — is the substance of TypeScript’s OOP surface. The discipline trades some of OOP’s substantive features (multiple inheritance, runtime polymorphism via virtual tables) for structural typing and substantial type-level expressiveness.