Static Members and Utility Classes


Understanding Static Members
Static members belong to the class itself, not to instances of the class. Think of them as class-level properties and methods that exist once, shared across all instances.
When you create instances of a class, each instance gets its own copy of instance properties and methods. But static members? There's only one copy, attached to the class. You access them through the class name, not through instances.
class Counter {
static count: number = 0; // Static property - shared
name: string; // Instance property - per instance
constructor(name: string) {
this.name = name;
Counter.count++; // Access static via class name
}
static getCount(): number {
// Static method
return Counter.count;
}
}
const c1 = new Counter("First");
const c2 = new Counter("Second");
const c3 = new Counter("Third");
console.log(Counter.count); // 3
console.log(Counter.getCount()); // 3
console.log(c1.name); // "First" (each instance has own name)
console.log(c2.name); // "Second"
console.log(c3.name); // "Third"
// console.log(c1.count); // Error: Property 'count' does not exist on type 'Counter'
// Static members accessed via class, not instancesEvery time we create a new Counter instance, the shared count property increments. All instances see the same count because there's only one copy.
Static vs Instance
Static members live on the class. Instance members live on each object created from the class. Use static for shared state or behavior that doesn't depend on instance data.
Static Properties
Static properties store data at the class level. They're initialized once and shared across all instances.
Configuration and Constants
class DatabaseConfig {
static readonly HOST: string = "localhost";
static readonly PORT: number = 5432;
static readonly MAX_CONNECTIONS: number = 100;
static readonly TIMEOUT_MS: number = 5000;
static getConnectionString(): string {
return `postgres://${this.HOST}:${this.PORT}`;
}
}
// Access without creating instances
console.log(DatabaseConfig.HOST); // "localhost"
console.log(DatabaseConfig.getConnectionString()); // "postgres://localhost:5432"
// No need to instantiate
// const config = new DatabaseConfig(); // Unnecessary!Using readonly with static creates true constants—values that can't be changed after initialization.
Counters and Registries
class User {
static totalUsers: number = 0;
static activeUsers: number = 0;
static userRegistry: Map<string, User> = new Map();
id: string;
name: string;
isActive: boolean = false;
constructor(name: string) {
this.id = `user_${++User.totalUsers}`;
this.name = name;
User.userRegistry.set(this.id, this);
}
activate(): void {
if (!this.isActive) {
this.isActive = true;
User.activeUsers++;
}
}
deactivate(): void {
if (this.isActive) {
this.isActive = false;
User.activeUsers--;
}
}
static getStats(): { total: number; active: number; inactive: number } {
return {
total: User.totalUsers,
active: User.activeUsers,
inactive: User.totalUsers - User.activeUsers,
};
}
static findById(id: string): User | undefined {
return User.userRegistry.get(id);
}
}
const alice = new User("Alice");
const bob = new User("Bob");
const charlie = new User("Charlie");
alice.activate();
bob.activate();
console.log(User.getStats());
// { total: 3, active: 2, inactive: 1 }
const found = User.findById("user_2");
console.log(found?.name); // "Bob"The registry and counters are shared across all instances. Every user is tracked in the same central registry.
Caching and Memoization
class Fibonacci {
private static cache: Map<number, number> = new Map([
[0, 0],
[1, 1],
]);
static calculate(n: number): number {
if (n < 0) {
throw new Error("Fibonacci not defined for negative numbers");
}
// Check cache first
if (this.cache.has(n)) {
return this.cache.get(n)!;
}
// Calculate and cache
const result = this.calculate(n - 1) + this.calculate(n - 2);
this.cache.set(n, result);
return result;
}
static getCacheSize(): number {
return this.cache.size;
}
static clearCache(): void {
this.cache.clear();
this.cache.set(0, 0);
this.cache.set(1, 1);
}
}
console.log(Fibonacci.calculate(10)); // 55
console.log(Fibonacci.calculate(20)); // 6765
console.log(Fibonacci.getCacheSize()); // 21 (cached 0-20)
Fibonacci.clearCache();
console.log(Fibonacci.getCacheSize()); // 2 (reset to base cases)The cache is shared across all calls to calculate(). Computed values persist between calls, making subsequent calculations faster.
Static Methods
Static methods operate at the class level. They can't access instance properties or methods (because there's no instance). They can only access other static members.
Factory Methods
Factory methods are static methods that create instances with preset configurations:
class User {
constructor(
public name: string,
public email: string,
public role: "admin" | "user" | "guest",
public verified: boolean = false
) {}
// Factory methods
static createAdmin(name: string, email: string): User {
return new User(name, email, "admin", true);
}
static createGuest(): User {
const guestId = Math.random().toString(36).substring(7);
return new User(`Guest_${guestId}`, `guest_${guestId}@temp.local`, "guest");
}
static createFromData(data: {
name: string;
email: string;
role?: "admin" | "user" | "guest";
}): User {
return new User(data.name, data.email, data.role || "user");
}
}
// Create users with factory methods
const admin = User.createAdmin("Alice", "[email protected]");
const guest = User.createGuest();
const user = User.createFromData({
name: "Bob",
email: "[email protected]",
});
console.log(admin); // User { name: 'Alice', role: 'admin', verified: true }
console.log(guest); // User { name: 'Guest_xyz', role: 'guest', verified: false }Factory methods provide named constructors with clear intent. createAdmin() is more expressive than new User(name, email, "admin", true).
Factory Pattern Benefits
Factory methods give you named constructors, validation before construction,
and the ability to return cached instances or subclass instances. They make
object creation more flexible and expressive than using new directly.
Validation and Parsing
class Email {
private constructor(private value: string) {}
static create(email: string): Email | null {
if (!this.isValid(email)) {
return null;
}
return new Email(email.toLowerCase().trim());
}
static isValid(email: string): boolean {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
}
static fromDomain(username: string, domain: string): Email | null {
return this.create(`${username}@${domain}`);
}
toString(): string {
return this.value;
}
getDomain(): string {
return this.value.split("@")[1];
}
}
const email1 = Email.create("[email protected]");
const email2 = Email.create("invalid-email");
const email3 = Email.fromDomain("bob", "company.com");
console.log(email1?.toString()); // "[email protected]"
console.log(email2); // null
console.log(email3?.getDomain()); // "company.com"
// Constructor is private - can't bypass validation
// const invalid = new Email("bad"); // Error: Constructor of class 'Email' is privateThe private constructor forces all creation through static factory methods, ensuring validation always runs.
Comparison and Utility Methods
class Point {
constructor(
public x: number,
public y: number
) {}
static distance(p1: Point, p2: Point): number {
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
return Math.sqrt(dx * dx + dy * dy);
}
static midpoint(p1: Point, p2: Point): Point {
return new Point((p1.x + p2.x) / 2, (p1.y + p2.y) / 2);
}
static origin(): Point {
return new Point(0, 0);
}
static fromPolar(radius: number, angle: number): Point {
return new Point(radius * Math.cos(angle), radius * Math.sin(angle));
}
}
const p1 = new Point(0, 0);
const p2 = new Point(3, 4);
console.log(Point.distance(p1, p2)); // 5
console.log(Point.midpoint(p1, p2)); // Point { x: 1.5, y: 2 }
console.log(Point.origin()); // Point { x: 0, y: 0 }
const p3 = Point.fromPolar(10, Math.PI / 4);
console.log(p3); // Point { x: 7.07..., y: 7.07... }Static methods work with multiple instances or create instances in specialized ways.
Utility Classes
Utility classes are classes that contain only static members. They're never instantiated—they just group related functionality.
Math Utilities
class MathUtils {
// Prevent instantiation
private constructor() {
throw new Error("MathUtils is a utility class and cannot be instantiated");
}
static readonly PI_SQUARED = Math.PI * Math.PI;
static readonly E_SQUARED = Math.E * Math.E;
static clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
static lerp(start: number, end: number, t: number): number {
return start + (end - start) * this.clamp(t, 0, 1);
}
static randomRange(min: number, max: number): number {
return min + Math.random() * (max - min);
}
static randomInt(min: number, max: number): number {
return Math.floor(this.randomRange(min, max + 1));
}
static degToRad(degrees: number): number {
return (degrees * Math.PI) / 180;
}
static radToDeg(radians: number): number {
return (radians * 180) / Math.PI;
}
}
console.log(MathUtils.clamp(150, 0, 100)); // 100
console.log(MathUtils.lerp(0, 100, 0.5)); // 50
console.log(MathUtils.randomInt(1, 10)); // Random int between 1-10
console.log(MathUtils.degToRad(90)); // 1.5707... (π/2)
// const utils = new MathUtils(); // Error: Cannot instantiate utility classThe private constructor prevents accidental instantiation. This class is purely a namespace for related functions.
String Utilities
class StringUtils {
private constructor() {}
static capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
}
static titleCase(str: string): string {
return str
.split(" ")
.map((word) => this.capitalize(word))
.join(" ");
}
static truncate(
str: string,
maxLength: number,
suffix: string = "..."
): string {
if (str.length <= maxLength) return str;
return str.substring(0, maxLength - suffix.length) + suffix;
}
static slugify(str: string): string {
return str
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_-]+/g, "-")
.replace(/^-+|-+$/g, "");
}
static countWords(str: string): number {
return str.trim().split(/\s+/).length;
}
static reverse(str: string): string {
return str.split("").reverse().join("");
}
}
console.log(StringUtils.capitalize("hello world")); // "Hello world"
console.log(StringUtils.titleCase("hello world")); // "Hello World"
console.log(StringUtils.truncate("This is a long sentence", 10)); // "This is..."
console.log(StringUtils.slugify("Hello World! 2024")); // "hello-world-2024"
console.log(StringUtils.countWords("The quick brown fox")); // 4Array Utilities
class ArrayUtils {
private constructor() {}
static chunk<T>(array: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
static unique<T>(array: T[]): T[] {
return [...new Set(array)];
}
static shuffle<T>(array: T[]): T[] {
const result = [...array];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
static groupBy<T>(array: T[], key: keyof T): Record<string, T[]> {
return array.reduce(
(groups, item) => {
const groupKey = String(item[key]);
if (!groups[groupKey]) {
groups[groupKey] = [];
}
groups[groupKey].push(item);
return groups;
},
{} as Record<string, T[]>
);
}
}
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9];
console.log(ArrayUtils.chunk(numbers, 3)); // [[1,2,3], [4,5,6], [7,8,9]]
const duplicates = [1, 2, 2, 3, 3, 3, 4];
console.log(ArrayUtils.unique(duplicates)); // [1, 2, 3, 4]
const users = [
{ name: "Alice", role: "admin" },
{ name: "Bob", role: "user" },
{ name: "Charlie", role: "admin" },
];
console.log(ArrayUtils.groupBy(users, "role"));
// { admin: [{name: 'Alice'...}, {name: 'Charlie'...}], user: [{name: 'Bob'...}] }Utility Classes vs Modules
Modern TypeScript often prefers exporting functions directly from modules rather than wrapping them in utility classes. Utility classes are a pattern from languages like Java and C#. For new code, consider using module-level functions instead unless you need static state or grouping benefits.
The Singleton Pattern
The singleton pattern ensures a class has only one instance and provides global access to it. Static members make this possible.
Basic Singleton
class Logger {
private static instance: Logger;
private logs: string[] = [];
// Private constructor prevents external instantiation
private constructor() {}
static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
log(message: string): void {
const timestamp = new Date().toISOString();
this.logs.push(`[${timestamp}] ${message}`);
}
getLogs(): string[] {
return [...this.logs];
}
clearLogs(): void {
this.logs = [];
}
}
const logger1 = Logger.getInstance();
const logger2 = Logger.getInstance();
logger1.log("First message");
logger2.log("Second message");
console.log(logger1 === logger2); // true (same instance)
console.log(logger1.getLogs()); // Both messages
console.log(logger2.getLogs()); // Same logs (shared state)
// const logger3 = new Logger(); // Error: Constructor is privateBoth logger1 and logger2 reference the same instance. All logs are stored in one place.
Application Configuration Singleton
class AppConfig {
private static instance: AppConfig;
private settings: Map<string, string> = new Map();
private constructor() {
// Load default config
this.settings.set("theme", "dark");
this.settings.set("language", "en");
this.settings.set("version", "1.0.0");
}
static getInstance(): AppConfig {
if (!AppConfig.instance) {
AppConfig.instance = new AppConfig();
}
return AppConfig.instance;
}
get(key: string): string | undefined {
return this.settings.get(key);
}
set(key: string, value: string): void {
this.settings.set(key, value);
}
getAll(): Record<string, string> {
return Object.fromEntries(this.settings);
}
}
const config = AppConfig.getInstance();
config.set("theme", "light");
// Elsewhere in the app
const sameConfig = AppConfig.getInstance();
console.log(sameConfig.get("theme")); // "light" (shared state)
console.log(sameConfig.getAll());
// { theme: 'light', language: 'en', version: '1.0.0' }Database Connection Pool Singleton
class ConnectionPool {
private static instance: ConnectionPool;
private connections: Array<{ id: number; inUse: boolean }> = [];
private nextId: number = 1;
private constructor(private maxConnections: number = 10) {
// Initialize pool
for (let i = 0; i < maxConnections; i++) {
this.connections.push({ id: this.nextId++, inUse: false });
}
}
static getInstance(maxConnections?: number): ConnectionPool {
if (!ConnectionPool.instance) {
ConnectionPool.instance = new ConnectionPool(maxConnections);
}
return ConnectionPool.instance;
}
acquire(): number | null {
const available = this.connections.find((conn) => !conn.inUse);
if (available) {
available.inUse = true;
return available.id;
}
return null; // Pool exhausted
}
release(id: number): void {
const conn = this.connections.find((c) => c.id === id);
if (conn) {
conn.inUse = false;
}
}
getStats(): { total: number; inUse: number; available: number } {
const inUse = this.connections.filter((c) => c.inUse).length;
return {
total: this.connections.length,
inUse,
available: this.connections.length - inUse,
};
}
}
const pool = ConnectionPool.getInstance(5);
const conn1 = pool.acquire();
const conn2 = pool.acquire();
console.log(pool.getStats()); // { total: 5, inUse: 2, available: 3 }
pool.release(conn1!);
console.log(pool.getStats()); // { total: 5, inUse: 1, available: 4 }class User
Static vs Instance Members
static totalUsers: numberstatic getInstance(): UserUser.memberNameOne copy shared by all instances
Accessed via class name
No instance required
name: stringemail: stringsave(): voidinstance.memberNameSeparate copy per instance
Accessed via instance variable
Requires new keyword
Static vs Instance Members
Understanding when to use static vs instance members is crucial.
When to Use Static
✅ Configuration and constants
class Config {
static readonly API_URL = "https://api.example.com";
static readonly TIMEOUT = 5000;
}✅ Factory methods
class User {
static createAdmin(name: string) {
/* ... */
}
static createGuest() {
/* ... */
}
}✅ Utility functions that don't need instance data
class StringUtils {
static capitalize(str: string) {
/* ... */
}
}✅ Shared counters or registries
class Entity {
static count = 0;
static registry = new Map();
}✅ Singleton pattern
class Logger {
private static instance: Logger;
static getInstance() {
/* ... */
}
}When to Use Instance
✅ Data that varies per object
class User {
name: string; // Different for each user
email: string;
}✅ Behavior that depends on instance state
class BankAccount {
balance: number;
withdraw(amount: number) {
/* depends on this.balance */
}
}✅ When polymorphism is needed
class Animal {
makeSound() {
/* overridden in subclasses */
}
}Common Patterns and Use Cases
ID Generator
class IdGenerator {
private static counters: Map<string, number> = new Map();
static generate(prefix: string = "id"): string {
const current = this.counters.get(prefix) || 0;
const next = current + 1;
this.counters.set(prefix, next);
return `${prefix}_${next}`;
}
static reset(prefix?: string): void {
if (prefix) {
this.counters.delete(prefix);
} else {
this.counters.clear();
}
}
}
console.log(IdGenerator.generate("user")); // "user_1"
console.log(IdGenerator.generate("user")); // "user_2"
console.log(IdGenerator.generate("product")); // "product_1"
console.log(IdGenerator.generate("user")); // "user_3"
IdGenerator.reset("user");
console.log(IdGenerator.generate("user")); // "user_1" (reset)Event Registry
class EventBus {
private static listeners: Map<string, Array<(...args: any[]) => void>> =
new Map();
static on(event: string, callback: (...args: any[]) => void): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event)!.push(callback);
}
static emit(event: string, ...args: any[]): void {
const callbacks = this.listeners.get(event);
if (callbacks) {
callbacks.forEach((callback) => callback(...args));
}
}
static off(event: string, callback?: (...args: any[]) => void): void {
if (!callback) {
this.listeners.delete(event);
} else {
const callbacks = this.listeners.get(event);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
}
}
}
EventBus.on("user:login", (user) => console.log(`${user} logged in`));
EventBus.on("user:logout", (user) => console.log(`${user} logged out`));
EventBus.emit("user:login", "Alice"); // "Alice logged in"
EventBus.emit("user:logout", "Alice"); // "Alice logged out"Object Pool
class ObjectPool<T> {
private static pools: Map<string, ObjectPool<any>> = new Map();
private available: T[] = [];
private inUse: Set<T> = new Set();
private constructor(
private factory: () => T,
private reset: (obj: T) => void,
initialSize: number = 10
) {
for (let i = 0; i < initialSize; i++) {
this.available.push(factory());
}
}
static create<T>(
name: string,
factory: () => T,
reset: (obj: T) => void,
initialSize?: number
): ObjectPool<T> {
if (!this.pools.has(name)) {
this.pools.set(name, new ObjectPool(factory, reset, initialSize));
}
return this.pools.get(name)!;
}
acquire(): T | null {
const obj = this.available.pop();
if (obj) {
this.inUse.add(obj);
return obj;
}
return null;
}
release(obj: T): void {
if (this.inUse.has(obj)) {
this.inUse.delete(obj);
this.reset(obj);
this.available.push(obj);
}
}
getStats() {
return {
available: this.available.length,
inUse: this.inUse.size,
};
}
}
// Usage
const bufferPool = ObjectPool.create(
"buffers",
() => new Uint8Array(1024),
(buffer) => buffer.fill(0),
5
);
const buffer1 = bufferPool.acquire();
const buffer2 = bufferPool.acquire();
console.log(bufferPool.getStats()); // { available: 3, inUse: 2 }
bufferPool.release(buffer1!);
console.log(bufferPool.getStats()); // { available: 4, inUse: 1 }Best Practices and When to Avoid
Best Practices
✅ Use readonly for static constants
class Config {
static readonly MAX_RETRIES = 3; // Can't be changed
}✅ Prevent instantiation of utility classes
class Utils {
private constructor() {} // Can't create instances
}✅ Use factories for complex object creation
class User {
static createAdmin(name: string) {
/* validation and setup */
}
}✅ Access static members via class name, not this
class Counter {
static count = 0;
static increment() {
Counter.count++; // ✅ Clear
// this.count++; // ❌ Confusing (works but unclear)
}
}When to Avoid
❌ Don't use static for data that should be instance-specific
// ❌ Bad: All users share the same name!
class User {
static name: string;
}
// ✅ Good: Each user has their own name
class UserGood {
name: string;
}❌ Don't use singletons when you need testability
// ❌ Hard to test (global state)
class Logger {
private static instance: Logger;
static getInstance() {
/* ... */
}
}
// ✅ Better: Dependency injection
class LoggerGood {
constructor() {}
}❌ Don't use utility classes when modules suffice
// ❌ Unnecessary class wrapper
class StringUtils {
static capitalize(s: string) {
/* ... */
}
}
// ✅ Better: Module-level functions
export function capitalize(s: string) {
/* ... */
}Testing Gotcha
Static state persists between tests unless explicitly reset. Always provide reset methods or use dependency injection when testability matters. Global state makes unit tests fragile and order-dependent.
What's Next
You now understand static members and utility classes. In the next posts, you'll learn:
- Abstract classes and methods for creating blueprints
- Class inheritance with the
superkeyword - Implementing interfaces in classes
- Parameter properties shorthand
Key Takeaways
Static members give you:
✅ Class-level functionality that doesn't require instances ✅ Shared state across all instances of a class ✅ Utility classes for grouping related functions ✅ Factory methods for flexible object creation ✅ Singleton pattern for globally shared resources ✅ Constants and configuration in one place
Use static members for shared data, utility functions, and factories. Use instance members for data and behavior that varies per object. When in doubt, start with instance members—they're more flexible and testable. Add static members only when you genuinely need class-level functionality or shared state.