TypeScript — Classes
TypeScript builds on ES2015+ classes with a rich type system for class members. You can annotate properties, control visibility with access modifiers, use concise parameter property syntax, and leverage structural typing when a class matches an interface.
Unlike plain JavaScript classes, TypeScript enforces member types at compile time and provides visibility controls that disappear at runtime (except for ES private fields). This gives you the ergonomics of a classical OOP language while compiling down to standard JavaScript classes.
note
Properties must be declared with their types, typically in the class body. TypeScript will enforce that values assigned in the constructor match the declared types.
| 1 | class Person { |
| 2 | // Property declarations with types |
| 3 | name: string; |
| 4 | age: number; |
| 5 | email: string; |
| 6 | tags: string[]; |
| 7 | |
| 8 | // Constructor with typed parameters |
| 9 | constructor(name: string, age: number, email: string) { |
| 10 | this.name = name; |
| 11 | this.age = age; |
| 12 | this.email = email; |
| 13 | this.tags = []; |
| 14 | } |
| 15 | |
| 16 | // Method with typed return |
| 17 | greet(): string { |
| 18 | return `Hello, I'm ${this.name}`; |
| 19 | } |
| 20 | |
| 21 | // Method with typed parameters |
| 22 | updateEmail(newEmail: string): void { |
| 23 | this.email = newEmail; |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | const alice = new Person("Alice", 30, "alice@example.com"); |
| 28 | console.log(alice.greet()); // "Hello, I'm Alice" |
| 29 | |
| 30 | // TypeScript catches type mismatches: |
| 31 | // alice.age = "thirty"; // Error: Type 'string' is not assignable to type 'number' |
| 1 | // Optional properties with ? |
| 2 | class Config { |
| 3 | host: string; |
| 4 | port?: number; // optional — can be undefined |
| 5 | timeout: number = 5000; // default value |
| 6 | |
| 7 | constructor(host: string, port?: number) { |
| 8 | this.host = host; |
| 9 | if (port !== undefined) { |
| 10 | this.port = port; |
| 11 | } |
| 12 | } |
| 13 | } |
| 14 | |
| 15 | const defaultConfig = new Config("localhost"); // port is undefined |
| 16 | const customConfig = new Config("localhost", 8080); |
| 17 | |
| 18 | // Property initialization checks (strict mode) |
| 19 | class MustInitialize { |
| 20 | name!: string; // definite assignment assertion — trust me, it's set |
| 21 | value: string; // Error: Property 'value' has no initializer |
| 22 | // and is not definitely assigned in the constructor |
| 23 | |
| 24 | constructor() { |
| 25 | this.initialize(); |
| 26 | } |
| 27 | |
| 28 | initialize(): void { |
| 29 | this.name = "initialized"; |
| 30 | this.value = "ok"; |
| 31 | } |
| 32 | } |
info
TypeScript provides three visibility modifiers: public (default), private, and protected. These are compile-time-only — they do not affect the compiled JavaScript.
| 1 | class BankAccount { |
| 2 | public owner: string; // accessible everywhere (default) |
| 3 | private balance: number; // only accessible within this class |
| 4 | protected accountNumber: string; // accessible in class and subclasses |
| 5 | |
| 6 | constructor(owner: string, initialDeposit: number) { |
| 7 | this.owner = owner; |
| 8 | this.balance = initialDeposit; |
| 9 | this.accountNumber = this.generateAccountNumber(); |
| 10 | } |
| 11 | |
| 12 | private generateAccountNumber(): string { |
| 13 | return Math.random().toString(36).slice(2); |
| 14 | } |
| 15 | |
| 16 | public deposit(amount: number): void { |
| 17 | if (amount <= 0) throw new Error("Amount must be positive"); |
| 18 | this.balance += amount; |
| 19 | } |
| 20 | |
| 21 | public withdraw(amount: number): boolean { |
| 22 | if (amount > this.balance) return false; |
| 23 | this.balance -= amount; |
| 24 | return true; |
| 25 | } |
| 26 | |
| 27 | public getBalance(): number { |
| 28 | return this.balance; |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | class SavingsAccount extends BankAccount { |
| 33 | private interestRate: number; |
| 34 | |
| 35 | constructor(owner: string, initialDeposit: number, interestRate: number) { |
| 36 | super(owner, initialDeposit); |
| 37 | this.interestRate = interestRate; |
| 38 | } |
| 39 | |
| 40 | public applyInterest(): void { |
| 41 | // Can access protected, but not private |
| 42 | console.log(`Applying interest to account ${this.accountNumber}`); |
| 43 | // this.balance // Error: Property 'balance' is private to BankAccount |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | const account = new BankAccount("Alice", 1000); |
| 48 | console.log(account.owner); // ok — public |
| 49 | // console.log(account.balance); // Error: Property 'balance' is private |
| 50 | // console.log(account.accountNumber); // Error: Property 'accountNumber' is protected |
warning
The readonly modifier prevents reassignment after initialization. Parameter properties let you declare and initialize a property in one line directly from the constructor parameter.
| 1 | // readonly — prevents reassignment |
| 2 | class Point { |
| 3 | readonly x: number; |
| 4 | readonly y: number; |
| 5 | |
| 6 | constructor(x: number, y: number) { |
| 7 | this.x = x; |
| 8 | this.y = y; |
| 9 | } |
| 10 | |
| 11 | scale(factor: number): Point { |
| 12 | return new Point(this.x * factor, this.y * factor); |
| 13 | } |
| 14 | } |
| 15 | |
| 16 | const p = new Point(3, 4); |
| 17 | // p.x = 5; // Error: Cannot assign to 'x' because it is a read-only property |
| 18 | |
| 19 | // Parameter properties — shorthand declaration + assignment |
| 20 | class User { |
| 21 | constructor( |
| 22 | public readonly id: number, // public + readonly + property |
| 23 | public name: string, // public property |
| 24 | private email: string, // private property |
| 25 | protected role: string = "user", // protected with default |
| 26 | readonly createdAt: Date = new Date() // readonly with default |
| 27 | ) {} |
| 28 | |
| 29 | display(): void { |
| 30 | console.log(`${this.name} (${this.email}) — ${this.role}`); |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | // Equivalent to traditional syntax: |
| 35 | class UserTraditional { |
| 36 | public readonly id: number; |
| 37 | public name: string; |
| 38 | private email: string; |
| 39 | protected role: string; |
| 40 | readonly createdAt: Date; |
| 41 | |
| 42 | constructor(id: number, name: string, email: string, role: string = "user") { |
| 43 | this.id = id; |
| 44 | this.name = name; |
| 45 | this.email = email; |
| 46 | this.role = role; |
| 47 | this.createdAt = new Date(); |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | // Parameter properties combine visibility, readonly, and type in one |
| 52 | // They are the idiomatic TypeScript pattern |
| 53 | const user = new User(1, "Alice", "alice@example.com"); |
| 54 | console.log(user.id); // 1 — public readonly |
best practice
TypeScript supports ES5+ getter/setter syntax with typed accessors. Getters and setters are type-checked and can have different visibility from the backing field.
| 1 | class Temperature { |
| 2 | private _celsius: number = 0; |
| 3 | |
| 4 | // Getter — must have the same type as the setter |
| 5 | get celsius(): number { |
| 6 | return this._celsius; |
| 7 | } |
| 8 | |
| 9 | // Setter — validates before assigning |
| 10 | set celsius(value: number) { |
| 11 | if (value < -273.15) { |
| 12 | throw new Error("Temperature below absolute zero"); |
| 13 | } |
| 14 | this._celsius = value; |
| 15 | } |
| 16 | |
| 17 | // Computed getter — no setter (read-only) |
| 18 | get fahrenheit(): number { |
| 19 | return (this._celsius * 9) / 5 + 32; |
| 20 | } |
| 21 | |
| 22 | // Getter/setter can differ in visibility |
| 23 | get debugInfo(): string { |
| 24 | return `Temp: ${this._celsius}°C / ${this.fahrenheit}°F`; |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | const temp = new Temperature(); |
| 29 | temp.celsius = 100; // setter validates |
| 30 | console.log(temp.celsius); // 100 — getter |
| 31 | console.log(temp.fahrenheit); // 212 — computed getter |
| 32 | |
| 33 | // temp.fahrenheit = 0; // Error: Cannot assign to 'fahrenheit' because it is a read-only property |
| 34 | |
| 35 | // Practical: lazy initialization with getter |
| 36 | class LazyConfig { |
| 37 | private _config: Record<string, string> | null = null; |
| 38 | |
| 39 | get config(): Record<string, string> { |
| 40 | if (!this._config) { |
| 41 | // Simulate expensive load |
| 42 | this._config = { host: "localhost", port: "8080" }; |
| 43 | } |
| 44 | return this._config; |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | const cfg = new LazyConfig(); |
| 49 | console.log(cfg.config); // loaded on first access |
info
Static properties and methods belong to the class itself, not instances. TypeScript supports static with all access modifiers, including private and readonly.
| 1 | class MathUtils { |
| 2 | // Static property |
| 3 | static readonly PI = 3.14159265359; |
| 4 | |
| 5 | // Private static — class-level internal state |
| 6 | private static instanceCount = 0; |
| 7 | |
| 8 | // Static method |
| 9 | static circleArea(radius: number): number { |
| 10 | return MathUtils.PI * radius * radius; |
| 11 | } |
| 12 | |
| 13 | static factorial(n: number): number { |
| 14 | if (n <= 1) return 1; |
| 15 | return n * MathUtils.factorial(n - 1); |
| 16 | } |
| 17 | |
| 18 | constructor() { |
| 19 | MathUtils.instanceCount++; |
| 20 | } |
| 21 | |
| 22 | static getInstanceCount(): number { |
| 23 | return MathUtils.instanceCount; |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | // Access static members through the class |
| 28 | console.log(MathUtils.PI); // 3.14159265359 |
| 29 | console.log(MathUtils.circleArea(5)); // 78.53981633975 |
| 30 | console.log(MathUtils.factorial(5)); // 120 |
| 31 | |
| 32 | // Static factory methods — common pattern |
| 33 | class UserID { |
| 34 | private constructor(public readonly value: string) {} |
| 35 | |
| 36 | static generate(): UserID { |
| 37 | return new UserID(crypto.randomUUID()); |
| 38 | } |
| 39 | |
| 40 | static fromString(value: string): UserID { |
| 41 | if (!value || value.length === 0) { |
| 42 | throw new Error("Invalid ID"); |
| 43 | } |
| 44 | return new UserID(value); |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | const id1 = UserID.generate(); |
| 49 | const id2 = UserID.fromString("abc-123"); |
| 50 | // new UserID("x"); // Error: constructor is private |
| 51 | |
| 52 | // Static members are inherited (unlike some languages) |
| 53 | class ExtendedMath extends MathUtils { |
| 54 | static cube(x: number): number { |
| 55 | return x * x * x; |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | console.log(ExtendedMath.PI); // 3.14159265359 — inherited |
| 60 | console.log(ExtendedMath.cube(3)); // 27 |
best practice
The implements keyword checks that a class satisfies an interface or type alias. It is a structural check — the class must have all required members with compatible types.
| 1 | interface Serializable { |
| 2 | toJSON(): Record<string, unknown>; |
| 3 | } |
| 4 | |
| 5 | interface Loggable { |
| 6 | getLogInfo(): string; |
| 7 | } |
| 8 | |
| 9 | // Single interface |
| 10 | class User implements Serializable { |
| 11 | constructor( |
| 12 | public id: number, |
| 13 | public name: string, |
| 14 | private email: string |
| 15 | ) {} |
| 16 | |
| 17 | toJSON(): Record<string, unknown> { |
| 18 | return { id: this.id, name: this.name }; |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | // Multiple interfaces — comma separated |
| 23 | class Product implements Serializable, Loggable { |
| 24 | constructor( |
| 25 | public id: number, |
| 26 | public name: string, |
| 27 | public price: number |
| 28 | ) {} |
| 29 | |
| 30 | toJSON(): Record<string, unknown> { |
| 31 | return { id: this.id, name: this.name, price: this.price }; |
| 32 | } |
| 33 | |
| 34 | getLogInfo(): string { |
| 35 | return `Product[${this.id}]: ${this.name} — $${this.price}`; |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | // implements with a type alias |
| 40 | type Point = { x: number; y: number }; |
| 41 | |
| 42 | class Coordinate implements Point { |
| 43 | constructor( |
| 44 | public x: number, |
| 45 | public y: number |
| 46 | ) {} |
| 47 | } |
| 48 | |
| 49 | // Class satisfies interface structurally — implements is optional but explicit |
| 50 | interface HasName { |
| 51 | name: string; |
| 52 | } |
| 53 | |
| 54 | class Person { |
| 55 | constructor(public name: string) {} |
| 56 | } |
| 57 | |
| 58 | const p: HasName = new Person("Alice"); // ok — structural compatibility |
| 59 | |
| 60 | // implements catches missing or mismatched members |
| 61 | // class BadProduct implements Serializable { |
| 62 | // // Error: Class 'BadProduct' does not implement 'Serializable' |
| 63 | // // Property 'toJSON' is missing |
| 64 | // constructor(public id: number) {} |
| 65 | // } |
info
Class expressions are unnamed classes that can be assigned to variables, passed as arguments, or returned from functions. TypeScript fully type-checks class expressions.
| 1 | // Anonymous class expression |
| 2 | const Logger = class { |
| 3 | constructor(private prefix: string) {} |
| 4 | |
| 5 | log(message: string): void { |
| 6 | console.log(`[${this.prefix}] ${message}`); |
| 7 | } |
| 8 | }; |
| 9 | |
| 10 | const logger = new Logger("API"); |
| 11 | logger.log("Server started"); |
| 12 | |
| 13 | // Named class expression (name is only visible inside) |
| 14 | const Database = class DB { |
| 15 | private connected = false; |
| 16 | |
| 17 | connect(): void { |
| 18 | this.connected = true; |
| 19 | console.log(`${DB.name} connected`); |
| 20 | } |
| 21 | }; |
| 22 | |
| 23 | // Class expression as return type |
| 24 | function createEntity<T>(data: T) { |
| 25 | return class Entity { |
| 26 | readonly id = crypto.randomUUID(); |
| 27 | readonly createdAt = new Date(); |
| 28 | readonly data: T = data; |
| 29 | |
| 30 | getInfo(): string { |
| 31 | return `Entity ${this.id}: ${JSON.stringify(this.data)}`; |
| 32 | } |
| 33 | }; |
| 34 | } |
| 35 | |
| 36 | const UserEntity = createEntity({ name: "Alice", age: 30 }); |
| 37 | const userEntity = new UserEntity(); |
| 38 | console.log(userEntity.getInfo()); |
| 39 | |
| 40 | // Mixin pattern with class expressions |
| 41 | function Timestamped<TBase extends new (...args: any[]) => any>(Base: TBase) { |
| 42 | return class extends Base { |
| 43 | readonly createdAt = new Date(); |
| 44 | readonly updatedAt = new Date(); |
| 45 | }; |
| 46 | } |
| 47 | |
| 48 | class UserData { |
| 49 | constructor(public name: string) {} |
| 50 | } |
| 51 | |
| 52 | const TimestampedUser = Timestamped(UserData); |
| 53 | const tsUser = new TimestampedUser("Bob"); |
| 54 | console.log(tsUser.createdAt); // Date — from the mixin |
info
TypeScript allows declaration merging — multiple declarations with the same name are merged. For classes, you can merge with interfaces and namespaces to add static members or prototype methods.
| 1 | // Class declaration |
| 2 | class Box<T> { |
| 3 | constructor(public value: T) {} |
| 4 | |
| 5 | getValue(): T { |
| 6 | return this.value; |
| 7 | } |
| 8 | } |
| 9 | |
| 10 | // Interface merging — adds instance members |
| 11 | interface Box<T> { |
| 12 | isEmpty(): boolean; |
| 13 | setValue(value: T): void; |
| 14 | } |
| 15 | |
| 16 | // After merging, Box has all three methods: |
| 17 | const box = new Box(42); |
| 18 | box.getValue(); // from class |
| 19 | box.isEmpty(); // from merged interface |
| 20 | box.setValue(100); // from merged interface |
| 21 | |
| 22 | // But you still need to implement them: |
| 23 | // The best use case is augmenting third-party types: |
| 24 | // declare module "express" { |
| 25 | // interface Request { |
| 26 | // user?: User; |
| 27 | // } |
| 28 | // } |
| 29 | |
| 30 | // Namespace merging — adds static members to the class |
| 31 | class Validator { |
| 32 | static patterns: Record<string, RegExp> = {}; |
| 33 | |
| 34 | static addPattern(name: string, pattern: RegExp): void { |
| 35 | Validator.patterns[name] = pattern; |
| 36 | } |
| 37 | |
| 38 | validate(value: string, patternName: string): boolean { |
| 39 | const pattern = Validator.patterns[patternName]; |
| 40 | if (!pattern) return false; |
| 41 | return pattern.test(value); |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | // Namespace with the same name |
| 46 | namespace Validator { |
| 47 | export const EMAIL_PATTERN = /^[^@]+@[^@]+.[^@]+$/; |
| 48 | export const URL_PATTERN = /^https?://.+/; |
| 49 | |
| 50 | export function isValidEmail(value: string): boolean { |
| 51 | return EMAIL_PATTERN.test(value); |
| 52 | } |
| 53 | |
| 54 | // Add to static patterns |
| 55 | export function initDefaults(): void { |
| 56 | Validator.addPattern("email", EMAIL_PATTERN); |
| 57 | Validator.addPattern("url", URL_PATTERN); |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | // Now Validator has both instance methods and static namespace members |
| 62 | Validator.initDefaults(); |
| 63 | console.log(Validator.isValidEmail("test@example.com")); // true |
| 64 | const v = new Validator(); |
| 65 | console.log(v.validate("test@example.com", "email")); // true |
best practice
1. Use parameter properties consistently — they reduce boilerplate and make constructors concise.
2. Mark properties as readonly unless mutation is required — this signals intent and prevents accidental reassignment.
3. Prefer private over protected by default. Only use protected when you have a concrete subclass that needs access.
4. Use implements to document contractual compliance. The compiler checks structurally anyway, but explicit implements provides better error messages.
5. Keep classes focused on a single responsibility. A class with more than 5-7 properties or methods likely needs splitting.
6. Favor composition over inheritance — use interfaces and mixins (class expressions) instead of deep class hierarchies.
7. Use the definite assignment assertion (!) sparingly. Prefer constructor initialization or default values.
8. For true runtime privacy, use ES # fields. For compile-time API enforcement, TypeScript's private is sufficient.