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
| Modifier | Inside Class | Subclass | Outside |
|---|---|---|---|
| public | O | O | O |
| protected | O | O | X |
| private | O | X | X |
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
| Concept | Keyword / Syntax | Key Characteristic |
|---|---|---|
| Class declaration | class Name {} | PascalCase, constructor is optional |
| Constructor | constructor() | Instance initialization, only one allowed |
| public | public (default) | Accessible everywhere |
| private | private | Class-internal only, compile-time check |
| protected | protected | Class + subclasses |
| readonly | readonly | Assignable only at declaration or in constructor |
| Parameter shorthand | constructor(private x: T) | Automatic property declaration and assignment |
| Interface implementation | implements I | Multiple interfaces supported |
| JS private | #field | Truly private at runtime |
| Class expression | const 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.