|$ curl https://forge-ai.dev/api/markdown?path=docs/typescript/classes
$cat docs/typescript-—-classes.md
updated Recently·14 min read·published

TypeScript — Classes

TypeScriptIntermediate
Introduction

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

TypeScript's class system is a superset of JavaScript classes. Every valid JS class is valid TS, but TS adds type annotations, modifiers, and structural compatibility checks that JS lacks.
Class Type Annotations

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.

class-annotations.ts
TypeScript
1class 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
27const alice = new Person("Alice", 30, "alice@example.com");
28console.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'
optional-defaults.ts
TypeScript
1// Optional properties with ?
2class 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
15const defaultConfig = new Config("localhost"); // port is undefined
16const customConfig = new Config("localhost", 8080);
17
18// Property initialization checks (strict mode)
19class 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

Use the definite assignment assertion (!) when a property is initialized outside the constructor body (e.g., via a method). Use it sparingly — if you can move the initialization into the constructor, do so.
Access Modifiers

TypeScript provides three visibility modifiers: public (default), private, and protected. These are compile-time-only — they do not affect the compiled JavaScript.

access-modifiers.ts
TypeScript
1class 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
32class 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
47const account = new BankAccount("Alice", 1000);
48console.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

TypeScript's private is erased at compile time. In the compiled JavaScript, the property is still accessible. Use JavaScript #private fields for true runtime privacy, or rely on TS private for API enforcement in your codebase.
readonly & Parameter Properties

The readonly modifier prevents reassignment after initialization. Parameter properties let you declare and initialize a property in one line directly from the constructor parameter.

parameter-properties.ts
TypeScript
1// readonly — prevents reassignment
2class 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
16const 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
20class 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:
35class 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
53const user = new User(1, "Alice", "alice@example.com");
54console.log(user.id); // 1 — public readonly

best practice

Parameter properties are idiomatic TypeScript. Use them consistently — they reduce boilerplate and make the constructor signature the single source of truth for class initialization. Combine with readonly for immutable properties.
Getters & Setters

TypeScript supports ES5+ getter/setter syntax with typed accessors. Getters and setters are type-checked and can have different visibility from the backing field.

getters-setters.ts
TypeScript
1class 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
28const temp = new Temperature();
29temp.celsius = 100; // setter validates
30console.log(temp.celsius); // 100 — getter
31console.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
36class 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
48const cfg = new LazyConfig();
49console.log(cfg.config); // loaded on first access

info

Getters without setters create read-only computed properties. Use them for derived data (full name from first/last, object transformations). Setters are ideal for validation hooks before state changes.
Static Members

Static properties and methods belong to the class itself, not instances. TypeScript supports static with all access modifiers, including private and readonly.

static-members.ts
TypeScript
1class 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
28console.log(MathUtils.PI); // 3.14159265359
29console.log(MathUtils.circleArea(5)); // 78.53981633975
30console.log(MathUtils.factorial(5)); // 120
31
32// Static factory methods — common pattern
33class 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
48const id1 = UserID.generate();
49const id2 = UserID.fromString("abc-123");
50// new UserID("x"); // Error: constructor is private
51
52// Static members are inherited (unlike some languages)
53class ExtendedMath extends MathUtils {
54 static cube(x: number): number {
55 return x * x * x;
56 }
57}
58
59console.log(ExtendedMath.PI); // 3.14159265359 — inherited
60console.log(ExtendedMath.cube(3)); // 27

best practice

Use private constructors with static factory methods to enforce object creation patterns. Static properties are useful for shared state, configuration, and constants that belong to the type rather than an instance.
implements Clause

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.

implements.ts
TypeScript
1interface Serializable {
2 toJSON(): Record<string, unknown>;
3}
4
5interface Loggable {
6 getLogInfo(): string;
7}
8
9// Single interface
10class 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
23class 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
40type Point = { x: number; y: number };
41
42class 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
50interface HasName {
51 name: string;
52}
53
54class Person {
55 constructor(public name: string) {}
56}
57
58const 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

TypeScript uses structural typing — implements is not required for compatibility, but it provides explicit documentation and better error messages. Use it when your class is intentionally designed to satisfy an interface contract.
Class Expressions

Class expressions are unnamed classes that can be assigned to variables, passed as arguments, or returned from functions. TypeScript fully type-checks class expressions.

class-expressions.ts
TypeScript
1// Anonymous class expression
2const Logger = class {
3 constructor(private prefix: string) {}
4
5 log(message: string): void {
6 console.log(`[${this.prefix}] ${message}`);
7 }
8};
9
10const logger = new Logger("API");
11logger.log("Server started");
12
13// Named class expression (name is only visible inside)
14const 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
24function 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
36const UserEntity = createEntity({ name: "Alice", age: 30 });
37const userEntity = new UserEntity();
38console.log(userEntity.getInfo());
39
40// Mixin pattern with class expressions
41function 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
48class UserData {
49 constructor(public name: string) {}
50}
51
52const TimestampedUser = Timestamped(UserData);
53const tsUser = new TimestampedUser("Bob");
54console.log(tsUser.createdAt); // Date — from the mixin

info

Class expressions shine in mixin patterns and factory functions. They let you compose behavior without inheritance hierarchies. The pattern of returning a class from a generic function is especially powerful.
Declaration Merging

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.

declaration-merging.ts
TypeScript
1// Class declaration
2class Box<T> {
3 constructor(public value: T) {}
4
5 getValue(): T {
6 return this.value;
7 }
8}
9
10// Interface merging — adds instance members
11interface Box<T> {
12 isEmpty(): boolean;
13 setValue(value: T): void;
14}
15
16// After merging, Box has all three methods:
17const box = new Box(42);
18box.getValue(); // from class
19box.isEmpty(); // from merged interface
20box.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
31class 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
46namespace 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
62Validator.initDefaults();
63console.log(Validator.isValidEmail("test@example.com")); // true
64const v = new Validator();
65console.log(v.validate("test@example.com", "email")); // true

best practice

Declaration merging is powerful for augmenting existing types (especially from third-party libraries) and grouping related utilities as static members. Avoid it for new code — explicit composition is clearer than implicit merging.
Best Practices

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.

$Blueprint — Engineering Documentation·Section ID: TS-CLASS·Revision: 1.0