Skip to main content
Advertisement

4.3 Static Members and Advanced Class Patterns

Static members are properties and methods that belong to the class itself, not to any instance. You call them directly with ClassName.method() without creating an object via new. They are used for things that "need to exist at the class level, not the instance level" — utility functions, factory methods, and singleton state management.

This chapter covers practical patterns: from the basics of static members, through ES2022 static initialization blocks, Singleton and Factory patterns, the this type for Fluent Interfaces, and getters/setters.


static Properties and Methods

Adding the static keyword makes a member class-level. It cannot be accessed from an instance — only through the class name.

class MathUtils {
static readonly PI: number = 3.14159265358979;

static circleArea(radius: number): number {
return MathUtils.PI * radius ** 2;
}

static degreesToRadians(degrees: number): number {
return degrees * (MathUtils.PI / 180);
}

static clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
}

console.log(MathUtils.circleArea(5)); // 78.539...
console.log(MathUtils.degreesToRadians(180)); // 3.14159...
console.log(MathUtils.clamp(150, 0, 100)); // 100

// const m = new MathUtils();
// m.circleArea(5); // Error: static members are not accessible on instances

Static Counter Example

Use a static property to manage shared state across all instances.

class UserSession {
private static activeCount: number = 0;
private static sessions: Map<string, UserSession> = new Map();

readonly sessionId: string;
readonly userId: number;
private startTime: Date;

constructor(userId: number) {
this.userId = userId;
this.sessionId = `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
this.startTime = new Date();
UserSession.activeCount++;
UserSession.sessions.set(this.sessionId, this);
}

end(): void {
UserSession.sessions.delete(this.sessionId);
UserSession.activeCount--;
console.log(`Session ${this.sessionId} ended. Active sessions: ${UserSession.activeCount}`);
}

static getActiveCount(): number {
return UserSession.activeCount;
}

static findByUserId(userId: number): UserSession | undefined {
for (const session of UserSession.sessions.values()) {
if (session.userId === userId) return session;
}
return undefined;
}
}

const s1 = new UserSession(1001);
const s2 = new UserSession(1002);
console.log(UserSession.getActiveCount()); // 2
s1.end();
console.log(UserSession.getActiveCount()); // 1

Static Initialization Block (ES2022)

Complex static initialization logic goes inside a static { } block. It runs once when the class is first loaded. TypeScript 4.4+ supports this feature.

class DatabaseConfig {
static readonly host: string;
static readonly port: number;
static readonly dbName: string;
static readonly connectionString: string;
static readonly isProduction: boolean;

static {
// Complex initialization logic: try/catch and branching are all fine here
DatabaseConfig.isProduction = process.env.NODE_ENV === "production";

if (DatabaseConfig.isProduction) {
DatabaseConfig.host = process.env.DB_HOST ?? "prod-db.example.com";
DatabaseConfig.port = parseInt(process.env.DB_PORT ?? "5432", 10);
DatabaseConfig.dbName = process.env.DB_NAME ?? "prod_db";
} else {
DatabaseConfig.host = "localhost";
DatabaseConfig.port = 5432;
DatabaseConfig.dbName = "dev_db";
}

DatabaseConfig.connectionString =
`postgresql://${DatabaseConfig.host}:${DatabaseConfig.port}/${DatabaseConfig.dbName}`;

console.log(
`DB config initialized: ${DatabaseConfig.connectionString}`
);
}
}

// The static block runs the first time the class is accessed
console.log(DatabaseConfig.connectionString);

Multiple static blocks can be declared; they execute in declaration order.

class MultiStageInit {
static stage1: string;
static stage2: string;

static {
MultiStageInit.stage1 = "Stage 1 initialized";
console.log(MultiStageInit.stage1);
}

static {
MultiStageInit.stage2 = `${MultiStageInit.stage1} — Stage 2 ready`;
console.log(MultiStageInit.stage2);
}
}

Singleton Pattern

Use this when only one instance of a class should exist across the entire application. Implement it with a private constructor and a static getInstance() method.

class Logger {
private static instance: Logger | null = null;
private logs: string[] = [];
private logLevel: "debug" | "info" | "warn" | "error" = "info";

// private constructor: new Logger() is not callable from outside
private constructor() {
console.log("Logger instance created (first time only)");
}

static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}

setLevel(level: "debug" | "info" | "warn" | "error"): void {
this.logLevel = level;
}

private shouldLog(level: "debug" | "info" | "warn" | "error"): boolean {
const levels = ["debug", "info", "warn", "error"];
return levels.indexOf(level) >= levels.indexOf(this.logLevel);
}

private log(level: "debug" | "info" | "warn" | "error", message: string): void {
if (!this.shouldLog(level)) return;
const entry = `[${new Date().toISOString()}] [${level.toUpperCase()}] ${message}`;
this.logs.push(entry);
console.log(entry);
}

debug(message: string): void { this.log("debug", message); }
info(message: string): void { this.log("info", message); }
warn(message: string): void { this.log("warn", message); }
error(message: string): void { this.log("error", message); }

getLogs(): string[] { return [...this.logs]; }

clearLogs(): void { this.logs = []; }
}

// Usage
const logger1 = Logger.getInstance();
const logger2 = Logger.getInstance();

console.log(logger1 === logger2); // true — same instance

logger1.info("Server started");
logger2.warn("Low memory warning"); // same instance as logger1

// new Logger(); // Error: private constructor

Factory Method Pattern

Instead of exposing the constructor directly, create objects through static factory methods. This is also known as the Named Constructor pattern.

class Color {
private constructor(
public readonly r: number,
public readonly g: number,
public readonly b: number,
public readonly a: number = 1
) {
if ([r, g, b].some((v) => v < 0 || v > 255)) {
throw new Error("RGB values must be in the range 0–255.");
}
if (a < 0 || a > 1) {
throw new Error("Alpha value must be in the range 0–1.");
}
}

// Factory methods — meaningful names for different construction approaches
static fromRGB(r: number, g: number, b: number): Color {
return new Color(r, g, b);
}

static fromHex(hex: string): Color {
const clean = hex.replace("#", "");
return new Color(
parseInt(clean.slice(0, 2), 16),
parseInt(clean.slice(2, 4), 16),
parseInt(clean.slice(4, 6), 16)
);
}

static fromRGBA(r: number, g: number, b: number, a: number): Color {
return new Color(r, g, b, a);
}

static white(): Color { return new Color(255, 255, 255); }
static black(): Color { return new Color(0, 0, 0); }
static transparent(): Color { return new Color(0, 0, 0, 0); }

toHex(): string {
const toHex = (n: number) => n.toString(16).padStart(2, "0");
return `#${toHex(this.r)}${toHex(this.g)}${toHex(this.b)}`;
}

toString(): string {
return this.a === 1
? `rgb(${this.r}, ${this.g}, ${this.b})`
: `rgba(${this.r}, ${this.g}, ${this.b}, ${this.a})`;
}

withAlpha(a: number): Color {
return new Color(this.r, this.g, this.b, a);
}
}

const red = Color.fromRGB(255, 0, 0);
const blue = Color.fromHex("#0000FF");
const semiTransparent = Color.fromRGBA(128, 128, 128, 0.5);

console.log(red.toHex()); // #ff0000
console.log(blue.toString()); // rgb(0, 0, 255)
console.log(semiTransparent.toString()); // rgba(128, 128, 128, 0.5)
console.log(Color.white().toHex()); // #ffffff

this Type — Fluent Interface (Method Chaining)

Using this as the return type keeps chaining intact even in subclasses.

class QueryBuilder {
protected conditions: string[] = [];
protected selectedColumns: string[] = ["*"];
protected tableName: string = "";
protected limitValue: number | null = null;
protected offsetValue: number | null = null;
protected orderByClause: string | null = null;

from(table: string): this {
this.tableName = table;
return this;
}

select(...columns: string[]): this {
this.selectedColumns = columns;
return this;
}

where(condition: string): this {
this.conditions.push(condition);
return this;
}

limit(n: number): this {
this.limitValue = n;
return this;
}

offset(n: number): this {
this.offsetValue = n;
return this;
}

orderBy(column: string, direction: "ASC" | "DESC" = "ASC"): this {
this.orderByClause = `${column} ${direction}`;
return this;
}

build(): string {
const cols = this.selectedColumns.join(", ");
let query = `SELECT ${cols} FROM ${this.tableName}`;
if (this.conditions.length > 0) {
query += ` WHERE ${this.conditions.join(" AND ")}`;
}
if (this.orderByClause) {
query += ` ORDER BY ${this.orderByClause}`;
}
if (this.limitValue !== null) {
query += ` LIMIT ${this.limitValue}`;
}
if (this.offsetValue !== null) {
query += ` OFFSET ${this.offsetValue}`;
}
return query;
}
}

// The this type keeps chaining unbroken in the subclass
class PostgresQueryBuilder extends QueryBuilder {
withNoLock(): this {
// Add a PostgreSQL hint
return this;
}

returning(...columns: string[]): this {
// Add a RETURNING clause
return this;
}
}

const query = new PostgresQueryBuilder()
.from("users")
.select("id", "name", "email")
.where("age > 18")
.where("status = 'active'")
.orderBy("created_at", "DESC")
.limit(20)
.offset(40)
.withNoLock() // subclass method
.build(); // chain is unbroken

console.log(query);
// SELECT id, name, email FROM users
// WHERE age > 18 AND status = 'active'
// ORDER BY created_at DESC LIMIT 20 OFFSET 40

getter/setter — Computed Properties and Validation

The get and set keywords let you use property-like syntax while executing logic internally.

class Temperature {
private _celsius: number;

constructor(celsius: number) {
this._celsius = celsius;
}

// getter: computed property
get celsius(): number {
return this._celsius;
}

// setter: includes validation
set celsius(value: number) {
if (value < -273.15) {
throw new Error("Temperature cannot go below absolute zero (-273.15°C).");
}
this._celsius = value;
}

get fahrenheit(): number {
return this._celsius * (9 / 5) + 32;
}

set fahrenheit(value: number) {
this.celsius = (value - 32) * (5 / 9);
}

get kelvin(): number {
return this._celsius + 273.15;
}

toString(): string {
return `${this._celsius}°C / ${this.fahrenheit.toFixed(1)}°F / ${this.kelvin.toFixed(2)}K`;
}
}

const temp = new Temperature(100);
console.log(temp.toString()); // 100°C / 212.0°F / 373.15K

temp.fahrenheit = 32;
console.log(temp.celsius); // 0
console.log(temp.toString()); // 0°C / 32.0°F / 273.15K

// temp.celsius = -300; // Error: below absolute zero

Practical Example: Singleton Logger, Chaining Query Builder, and Config Manager

Config Manager (Singleton + getter/setter)

type LogLevel = "debug" | "info" | "warn" | "error";
type Environment = "development" | "staging" | "production";

interface AppSettings {
env: Environment;
logLevel: LogLevel;
maxRetries: number;
timeout: number;
apiBaseUrl: string;
}

class AppConfig {
private static instance: AppConfig;
private settings: AppSettings;

private constructor() {
// Default values
this.settings = {
env: (process.env.NODE_ENV as Environment) ?? "development",
logLevel: "info",
maxRetries: 3,
timeout: 5000,
apiBaseUrl: "https://api.example.com",
};
}

static getInstance(): AppConfig {
if (!AppConfig.instance) {
AppConfig.instance = new AppConfig();
}
return AppConfig.instance;
}

get env(): Environment { return this.settings.env; }
get logLevel(): LogLevel { return this.settings.logLevel; }

set logLevel(level: LogLevel) {
this.settings.logLevel = level;
console.log(`Log level changed to: ${level}`);
}

get maxRetries(): number { return this.settings.maxRetries; }

set maxRetries(value: number) {
if (value < 0 || value > 10) {
throw new Error("maxRetries must be between 0 and 10.");
}
this.settings.maxRetries = value;
}

get isProduction(): boolean {
return this.settings.env === "production";
}

get apiBaseUrl(): string { return this.settings.apiBaseUrl; }

// Fluent-style bulk update
configure(partial: Partial<AppSettings>): this {
Object.assign(this.settings, partial);
return this;
}

snapshot(): Readonly<AppSettings> {
return { ...this.settings };
}
}

// Usage
const config = AppConfig.getInstance();
config
.configure({ timeout: 10000, maxRetries: 5 });

config.logLevel = "debug";
console.log(config.snapshot());
console.log(config.isProduction); // false (development environment)

Pro Tips

static Members and Inheritance

Static members are accessible in subclasses. Using this inside a static method preserves the subclass context.

class Base {
static type: string = "Base";

static create(): Base {
console.log(`Creating: ${this.type}`);
return new this(); // new this() creates an instance of the current class
}
}

class Child extends Base {
static type: string = "Child";
}

const b = Base.create(); // Creating: Base
const c = Child.create(); // Creating: Child — subclass context is preserved

Static Factory vs Constructor

CriterionConstructorStatic Factory
NameNone (class name)Can have a meaningful name
CachingNot possiblePossible (singleton, cached return)
Return typeAlways the classCan return a subtype
OverloadingLimitedFully flexible signatures
Failure handlingException onlyCan return null / undefined / Result
class Connection {
private static pool: Connection[] = [];

private constructor(private url: string) {}

// Return from cache or create a new connection
static getConnection(url: string): Connection {
const cached = Connection.pool.find((c) => c.url === url);
if (cached) {
console.log("Returning cached connection");
return cached;
}
const conn = new Connection(url);
Connection.pool.push(conn);
return conn;
}

// Return null on failure (impossible with a constructor)
static tryCreate(url: string): Connection | null {
try {
if (!url.startsWith("http")) return null;
return new Connection(url);
} catch {
return null;
}
}
}

Summary

ConceptSyntaxKey Characteristic
Static propertystatic prop: TClass-level state, shared across instances
Static methodstatic method()Called without an instance
Static initialization blockstatic { }Complex initialization, try/catch supported
Singletonprivate constructor + static getInstance()Guarantees a single instance
Factory methodstatic create()Meaningful names, caching, flexible return
this typeReturn type thisPreserves subclass chaining
getterget prop()Computed property, can be read-only
setterset prop(v)Validation and side effects

In the next chapter we learn TypeScript decorators — a powerful metaprogramming tool for injecting metadata or transforming behavior on classes, methods, properties, and parameters. We will explore them alongside real-world NestJS and TypeORM examples.

Advertisement