Skip to main content
Advertisement

4.1 Class Basics

In TypeScript, a class is a structure that layers the type system on top of JavaScript ES6 class syntax. The core role of a class is to bundle data of the same kind together with the operations that work on that data into a single unit. Developers coming from Java or C# will find this familiar, but keep in mind that TypeScript classes compile down to ordinary JavaScript functions and prototypes at runtime.

This chapter systematically covers the patterns you must know in practice: from basic class declarations, through access modifiers, readonly, constructor parameter shorthand, and interface implementation.


Class Declaration and Constructor

This is the most basic way to declare a class. Use the class keyword, the class name in PascalCase, and define properties and methods inside curly braces.

class Person {
name: string;
age: number;

constructor(name: string, age: number) {
this.name = name;
this.age = age;
}

greet(): string {
return `Hello, my name is ${this.name} and I am ${this.age} years old.`;
}
}

const alice = new Person("Alice", 30);
console.log(alice.greet()); // Hello, my name is Alice and I am 30 years old.
console.log(alice.name); // Alice

TypeScript checks both property declarations and their assignments. If you forget to assign this.name inside the constructor, you get a "Property 'name' has no initializer" error.

strictPropertyInitialization

In strict mode in tsconfig.json, strictPropertyInitialization is enabled. When this option is on, you must either initialize the property in the constructor, use the ! definite-assignment assertion operator, or provide a default value.

class Example {
a: string; // Error: must be initialized in constructor
b: string = ""; // OK: default value provided
c!: string; // OK: asserted to be definitely initialized later
d: string | undefined; // OK: undefined is allowed
}

Access Modifiers — public, private, protected

TypeScript provides three access modifiers. These are compile-time-only rules (at runtime they are ordinary JS properties).

public — the default

When no keyword is given, the member is public. It is accessible anywhere inside or outside the class.

class Dog {
public name: string; // explicit public (same as omitting it)

constructor(name: string) {
this.name = name;
}

public bark(): void {
console.log(`${this.name}: Woof!`);
}
}

const dog = new Dog("Buddy");
dog.name = "Max"; // OK
dog.bark(); // OK

private — class-internal only

A member declared private is accessible only from within that class. Subclasses cannot access it either.

class Counter {
private count: number = 0;

increment(): void {
this.count++; // OK: inside the class
}

getCount(): number {
return this.count; // OK: inside the class
}
}

const c = new Counter();
c.increment();
console.log(c.getCount()); // 1
// c.count; // Error: cannot access private member from outside

protected — shared within the inheritance hierarchy

protected is accessible inside the class and in subclasses, but not from outside.

class Animal {
protected species: string;

constructor(species: string) {
this.species = species;
}

protected describe(): string {
return `I am a ${this.species}.`;
}
}

class Cat extends Animal {
private name: string;

constructor(name: string) {
super("cat");
this.name = name;
}

introduce(): string {
// can access protected members
return `${this.describe()} My name is ${this.name}.`;
}
}

const cat = new Cat("Whiskers");
console.log(cat.introduce()); // I am a cat. My name is Whiskers.
// cat.species; // Error: cannot access protected member from outside
// cat.describe(); // Error: cannot access protected method from outside

Access Modifier Comparison

ModifierInside ClassSubclassOutside
publicOOO
protectedOOX
privateOXX

readonly Properties

A property marked with readonly can only be assigned at the point of declaration or inside the constructor. Any re-assignment afterward causes a compile error.

class Config {
readonly appName: string;
readonly version: string = "1.0.0"; // assigned at declaration

constructor(appName: string) {
this.appName = appName; // OK: assignment in constructor
}

updateVersion(): void {
// this.version = "2.0.0"; // Error: cannot change readonly property
// this.appName = "other"; // Error: cannot change readonly property
}
}

const cfg = new Config("MyApp");
console.log(cfg.appName); // MyApp
// cfg.appName = "Other"; // Error

readonly can be combined with an access modifier.

class Point {
constructor(
public readonly x: number,
public readonly y: number
) {}

distanceTo(other: Point): number {
const dx = this.x - other.x;
const dy = this.y - other.y;
return Math.sqrt(dx * dx + dy * dy);
}
}

const p1 = new Point(0, 0);
const p2 = new Point(3, 4);
console.log(p1.distanceTo(p2)); // 5
// p1.x = 10; // Error

Constructor Parameter Shorthand (Parameter Properties)

This is TypeScript-only syntax. When you prefix a constructor parameter with an access modifier or readonly, TypeScript automatically declares a property of the same name and assigns the value to it. This dramatically reduces boilerplate.

// Old approach (lots of boilerplate)
class UserOld {
public id: number;
private name: string;
protected email: string;
readonly createdAt: Date;

constructor(id: number, name: string, email: string, createdAt: Date) {
this.id = id;
this.name = name;
this.email = email;
this.createdAt = createdAt;
}
}

// Shorthand syntax (identical result)
class User {
constructor(
public id: number,
private name: string,
protected email: string,
readonly createdAt: Date
) {}

getName(): string {
return this.name;
}
}

const user = new User(1, "Alice", "alice@example.com", new Date());
console.log(user.id); // 1 (public)
console.log(user.getName()); // Alice
// user.name; // Error: private

A parameter without an access modifier or readonly does not get the shorthand treatment. At least one of these must be present for the parameter to become a property.


Classes and Interfaces — implements

A class can implement an interface with the implements keyword. Every member declared in the interface must be implemented.

interface Printable {
print(): void;
}

interface Serializable {
serialize(): string;
deserialize(data: string): void;
}

class Document implements Printable, Serializable {
private content: string;

constructor(content: string) {
this.content = content;
}

print(): void {
console.log(this.content);
}

serialize(): string {
return JSON.stringify({ content: this.content });
}

deserialize(data: string): void {
const parsed = JSON.parse(data);
this.content = parsed.content;
}
}

const doc = new Document("Hello TypeScript");
doc.print(); // Hello TypeScript
const serialized = doc.serialize();
console.log(serialized); // {"content":"Hello TypeScript"}

// Use as interface type
const printable: Printable = new Document("World");
printable.print();

implements only performs type checking. The actual implementation logic must be written inside the class.


Using a Class as a Type

In TypeScript, a class plays two type roles.

  • Instance type: the type of an object created with new ClassName()
  • Class type (constructor type): the class itself — a type that can be called with new
class Robot {
constructor(public name: string) {}
greet(): string {
return `I am robot ${this.name}.`;
}
}

// Instance type: Robot
const r: Robot = new Robot("R2-D2");

// Class type (constructor type)
type RobotConstructor = typeof Robot;
const RobotClass: RobotConstructor = Robot;
const r2 = new RobotClass("C-3PO");
console.log(r2.greet());

// Expressing a constructor signature directly
type Newable<T> = new (...args: any[]) => T;
function createInstance<T>(cls: Newable<T>, ...args: any[]): T {
return new cls(...args);
}

const r3 = createInstance(Robot, "BB-8");
console.log(r3.greet()); // I am robot BB-8.

Practical Example: BankAccount Class

A bank account model that puts encapsulation, access modifiers, readonly, and interfaces all to work.

interface AccountInfo {
readonly accountNumber: string;
owner: string;
getBalance(): number;
}

interface Transactionable {
deposit(amount: number): void;
withdraw(amount: number): boolean;
getHistory(): TransactionRecord[];
}

interface TransactionRecord {
type: "deposit" | "withdraw";
amount: number;
balance: number;
timestamp: Date;
}

class BankAccount implements AccountInfo, Transactionable {
readonly accountNumber: string;
owner: string;
private balance: number;
private history: TransactionRecord[] = [];

constructor(
accountNumber: string,
owner: string,
initialBalance: number = 0
) {
this.accountNumber = accountNumber;
this.owner = owner;
this.balance = initialBalance;
}

getBalance(): number {
return this.balance;
}

deposit(amount: number): void {
if (amount <= 0) {
throw new Error("Deposit amount must be greater than zero.");
}
this.balance += amount;
this.recordTransaction("deposit", amount);
console.log(`Deposited $${amount}. Balance: $${this.balance}`);
}

withdraw(amount: number): boolean {
if (amount <= 0) {
throw new Error("Withdrawal amount must be greater than zero.");
}
if (amount > this.balance) {
console.log("Insufficient funds");
return false;
}
this.balance -= amount;
this.recordTransaction("withdraw", amount);
console.log(`Withdrew $${amount}. Balance: $${this.balance}`);
return true;
}

getHistory(): TransactionRecord[] {
// Return a copy so callers cannot mutate the internal array
return [...this.history];
}

printStatement(): void {
console.log(`\n=== Statement (${this.owner}) ===`);
console.log(`Account: ${this.accountNumber}`);
this.history.forEach((record, index) => {
const typeLabel = record.type === "deposit" ? "Deposit" : "Withdrawal";
console.log(
`${index + 1}. [${typeLabel}] $${record.amount} | Balance: $${record.balance}`
);
});
console.log(`Current Balance: $${this.balance}\n`);
}

private recordTransaction(
type: "deposit" | "withdraw",
amount: number
): void {
this.history.push({
type,
amount,
balance: this.balance,
timestamp: new Date(),
});
}
}

// Usage
const account = new BankAccount("1234-5678", "John Smith", 1000);
account.deposit(500); // Deposited $500. Balance: $1500
account.withdraw(300); // Withdrew $300. Balance: $1200
account.withdraw(2000); // Insufficient funds
account.printStatement();

// accountNumber is readonly
// account.accountNumber = "0000-0000"; // Error
// account.balance; // Error: private

Pro Tips

JS private (#) vs TypeScript private

TypeScript's private is a compile-time-only check. In the compiled JavaScript it becomes an ordinary, accessible property. JavaScript's # private fields, by contrast, are truly private at runtime as well.

class Comparison {
private tsPrivate: string = "TS private"; // publicly accessible at runtime
#jsPrivate: string = "JS private"; // truly private at runtime

getTs(): string { return this.tsPrivate; }
getJs(): string { return this.#jsPrivate; }
}

const obj = new Comparison();
// (obj as any).tsPrivate; // accessible at runtime (only TS check is bypassed)
// (obj as any).#jsPrivate; // SyntaxError — completely blocked at runtime

Use # for data that needs real runtime security (e.g., password hashes), and private to express design intent.

Class Expressions

Classes can also be used as expressions. This is useful for anonymous classes and dynamic class creation.

// Anonymous class expression
const Greeter = class {
greet(name: string): string {
return `Hello, ${name}!`;
}
};

// Named class expression (the name is only usable internally)
const Counter = class CounterClass {
private count = 0;
increment(): CounterClass {
this.count++;
return this;
}
value(): number { return this.count; }
};

const c = new Counter();
console.log(c.increment().increment().value()); // 2

// Useful in mixin patterns
type Constructor<T = {}> = new (...args: any[]) => T;

function Timestamped<TBase extends Constructor>(Base: TBase) {
return class extends Base {
createdAt = new Date();
};
}

class Article {
constructor(public title: string) {}
}

const TimestampedArticle = Timestamped(Article);
const article = new TimestampedArticle("TypeScript Classes");
console.log(article.title); // TypeScript Classes
console.log(article.createdAt); // creation timestamp

Structural Typing and Classes

TypeScript uses a structural type system. Two classes with different names are compatible as long as they have the same structure.

class Cat {
name: string;
constructor(name: string) { this.name = name; }
meow(): string { return "Meow"; }
}

class Robot {
name: string;
constructor(name: string) { this.name = name; }
meow(): string { return "Beep boop"; }
}

function makeSound(animal: Cat): void {
console.log(animal.meow());
}

const r = new Robot("R2");
makeSound(r); // OK — structures are identical so they are compatible

Because of this behavior, TypeScript class type checking is based on structure, not name.


Summary

ConceptKeyword / SyntaxKey Characteristic
Class declarationclass Name {}PascalCase, constructor is optional
Constructorconstructor()Instance initialization, only one allowed
publicpublic (default)Accessible everywhere
privateprivateClass-internal only, compile-time check
protectedprotectedClass + subclasses
readonlyreadonlyAssignable only at declaration or in constructor
Parameter shorthandconstructor(private x: T)Automatic property declaration and assignment
Interface implementationimplements IMultiple interfaces supported
JS private#fieldTruly private at runtime
Class expressionconst Cls = class {}Dynamic class creation, mixins

In the next chapter we explore abstract classes and advanced OOP patterns through multiple interface implementations. We will see in practice how abstract classes differ from ordinary classes and interfaces, and when to use each.

Advertisement