Hardcoding static classes into your TypeScript architecture often leads to fragile Jest test suites and tightly coupled code that ignores modern dependency injection. If you have a StringUtils class with a dozen static methods and you are wondering whether to keep adding to it or switch to plain module functions, this is where the decision happens.

FeatureStatic ClassES6 Module
Tree-ShakingPoor (bundles the entire class)Excellent (drops unused functions)
Mocking in JestRequires manual spyOn and cleanupSimple module mocking
State ManagementGlobal state risks memory leaksScoped tightly to module execution
Framework FitAnti-pattern in NestJS / AngularStandard in React / Express

Why TypeScript Lacks a Native Static Class Keyword

JavaScript does not have a concept of static classes at its core. TypeScript builds directly on top of JavaScript prototype mechanics. You can define static properties and static methods inside a regular class, but you cannot declare an entire class as static.

Developers moving from C# or Java often look for a way to group utility functions under a single namespace. You might try to force a class to act like a static container. This creates a structure where the class itself is never instantiated, acting merely as a bucket for functions.

3 Ways to Prevent Class Instantiation

When you build a utility class, you must ensure no one accidentally creates an instance of it using the new keyword. TypeScript offers three distinct patterns to block instantiation.

The Traditional Method: Private Constructor

The most common approach involves adding a private constructor to the class. This tells the TypeScript compiler to throw an error if anyone attempts to instantiate it.

class DateUtils {
  private constructor() {}
  static formatDate(date: Date): string {
    return date.toISOString();
  }
}

This solves the compile-time problem. However, a developer using plain JavaScript can still bypass this and instantiate the class at runtime.

The Modern Approach: Abstract Class

Using the abstract keyword is a cleaner alternative. Abstract classes cannot be instantiated by definition.

abstract class MathHelpers {
  static calculateTax(amount: number): number {
    return amount * 1.2;
  }
}

This clearly communicates your architectural intent. You are telling other developers that this class is a structural blueprint or a static container, not a regular object template.

The Aggressive Defense: Error-Throwing Constructor

If you are publishing a library and need strict runtime protection against plain JavaScript users, you can throw a runtime error inside the constructor.

class StringFormatter {
  constructor() {
    throw new Error('StringFormatter is a static class and cannot be instantiated.');
  }
  static capitalize(text: string): string {
    return text.charAt(0).toUpperCase() + text.slice(1);
  }
}

This method provides absolute security at the cost of slight performance overhead during the initial parse.

Static Class vs ES6 Modules: An Architectural Comparison

Choosing between a static class and an ES6 module goes beyond simple syntax preferences. Your choice directly impacts the performance and stability of your application.

Tree-Shaking and Bundle Size

Modern bundlers like Webpack and Vite struggle to tree-shake static classes. When you import a single static method from a class, the bundler usually includes the entire class object in the final output.

If your static class has 20 methods and you only use one, the remaining 19 methods bloat your bundle size. ES6 modules solve this cleanly. By exporting standalone functions, the bundler effortlessly removes unused code. Switching from static utility classes to ES6 modules measurably reduces your shared utility bundle size, especially in projects where utility files accumulate unused methods over time.

State Management and Node.js Memory Leaks

Static properties maintain global state across your application. In a long-running Node.js server, this behavior introduces critical memory leak risks.

When you store data in a static array, that array lives inside the Node.js module cache. The garbage collector never clears it until the server restarts. A simple caching mechanism built with static properties can quickly consume all available server memory under heavy load. Handle state through dedicated memory stores like Redis or rely on framework-level dependency injection.

Testability: Mocking Static Methods with Jest

Testing code tightly coupled to static classes is a frustrating experience. Static methods resist easy mocking and often pollute the global test environment.

Fragile Tests with jest.spyOn

To mock a static method, you have to spy on the class prototype. This requires meticulous cleanup after every single test block to prevent side effects.

import { Logger } from './logger';
test('should log error', () => {
  const spy = jest.spyOn(Logger, 'error').mockImplementation(() => {});
  // Test execution here
  spy.mockRestore(); // Forgetting this breaks other tests
});

If a test fails before reaching the restore command, the mock leaks into the next test suite. This creates false positives and highly brittle CI pipelines.

Why You Should Prefer Dependency Injection

Dependency Injection passes instances of services into your classes instead of relying on hardcoded static methods. This makes testing trivial. You simply pass a fake mock object into the constructor during testing. There is no need for complex spying or global state cleanup.

Framework Perspectives: NestJS and Angular

Modern enterprise frameworks heavily discourage static classes. Angular and NestJS are built around sophisticated Inversion of Control containers.

When you use a static class in NestJS, you completely bypass the framework lifecycle hooks. You cannot inject environment variables, database connections, or request-scoped context into a static method. Everything must be passed manually as parameters. Always prefer creating an @Injectable() service over a static class when working within these ecosystems.

Static Class Audit Checklist

Before refactoring anything, run your static class through these five questions. Each "yes" is a signal that a module would serve you better.

QuestionIf Yes
Does your bundler need to tree-shake this file?Refactor to module
Are you mocking this class in Jest tests?Refactor to module
Is this class used inside NestJS or Angular?Refactor to module
Does the class hold mutable static properties?Refactor or use DI
Is this class imported in more than 5 files?Refactor to module

If all five answers are "no", the static class is probably fine. A pure utility class with immutable constants and zero side effects is exactly where the pattern earns its place.

Refactoring Legacy Code: From Static Class to Module

Converting an old static class into a modern ES6 module is a straightforward process that instantly improves your code quality.

Legacy static class pattern:

export class StringUtils {
  private constructor() {}
  static slugify(text: string): string {
    return text.toLowerCase().trim();
  }
  static truncate(text: string, length: number): string {
    return text.substring(0, length);
  }
}

Modern ES6 module pattern:

export const slugify = (text: string): string => {
  return text.toLowerCase().trim();
};
export const truncate = (text: string, length: number): string => {
  return text.substring(0, length);
};

Consumers now import exactly what they need. The syntax is cleaner, the bundle size is smaller, and the functions are independently testable. This is exactly the refactor the no-extraneous-class ESLint rule pushes you toward.

Enforcing the Pattern: ESLint no-extraneous-class

The TypeScript community recognizes the issues with static classes. The popular typescript-eslint plugin includes a specific rule named no-extraneous-class.

This rule throws a linting error if you create a class containing only static members. It actively forces your engineering team to adopt ES6 modules for utility files. Enabling this rule prevents developers from carrying C# habits into your TypeScript codebase.

{
  "rules": {
    "@typescript-eslint/no-extraneous-class": "error"
  }
}

If you are migrating a large codebase, start with "warn" instead of "error" to identify the scope before enforcing it strictly. The refactor is usually mechanical and can be done incrementally file by file.