Leaning heavily on static classes in TypeScript usually comes back to bite you down the road. You end up with fragile Jest suites, tight coupling, and code that fights against modern dependency injection. If your project has a bloated StringUtils class sitting around and you're debating whether to keep tacking helpers onto it or just break it out into plain exported functions, you're standing at a pretty common crossroads.
| Feature | Static Class | ES6 Module |
|---|---|---|
| Tree-Shaking | Pretty poor (pulls the whole class into the build) | Spot on (drops anything you don't import) |
| Mocking in Jest | Tedious setup with manual spyOn and tear-downs |
Straightforward module-level mocking |
| State Management | Sneaky global state that loves causing memory leaks | Cleanly scoped to module execution |
| Framework Fit | An anti-pattern in NestJS and Angular | The bread and butter of React and Express |
Why TypeScript Doesn't Have a Real Static Class Keyword
Under the hood, JavaScript has never had a native concept of a static class. Because TypeScript sits directly on top of JavaScript's prototypal foundation, it inherits those same rules. You can certainly slap static onto individual methods and fields inside a class, but there's no way to mark an entire class as static at the language level.
Folks migrating over from Java or C# naturally look for a tidy way to bucket related utilities under a shared namespace. The temptation is to force a class to act like a static container. What you actually get, though, is a hollow shell - a class nobody ever instantiates that exists purely to hold loose functions.
3 Ways to Keep People from Instantiating Your Class
If you really do need a dedicated utility class, you have to actively prevent someone on your team from calling new on it. TypeScript gives you three practical patterns to shut that down.
The Classic Approach: Private Constructor
The old-school trick is simply dropping an empty private constructor right inside the class body. That tells the TypeScript compiler to scream whenever someone tries to instantiate it.
TypeScript
class DateUtils {
private constructor() {}
static formatDate(date: Date): string {
return date.toISOString();
}
}This works great while you're in TypeScript land. But remember, the second someone consumes this from plain JavaScript - or bypasses the compiler checks - they can still create an instance at runtime without breaking a sweat.
The Cleaner Modern Take: Abstract Class
A slicker, more idiomatic workaround is reaching for the abstract keyword. By definition, abstract classes can't be spun up with new.
TypeScript
abstract class MathHelpers {
static calculateTax(amount: number): number {
return amount * 1.2;
}
}This makes your intent immediately clear to anyone reading the code. You're signaling upfront that the class isn't meant to be instantiated as an object; it's just functioning as a structural bucket for some static logic.
The Nuclear Option: Throwing an Error in the Constructor
If you're building a public library and need bulletproof protection against plain JavaScript consumers, you can just have the constructor deliberately explode at runtime.
TypeScript
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);
}
}Nobody is getting around that guardrail, pure JS or not. The tradeoff is a tiny bit of extra runtime overhead and boilerplate that you rarely actually need in everyday app code.
Static Classes vs. ES6 Modules: The Architectural Reality
Deciding between a static class and a plain ES6 module isn't just an aesthetic debate. The choice ripples directly into your app's bundle footprint, runtime performance, and overall maintainability.
Tree-Shaking and Bundle Bloat
Tools like Vite and Webpack really stumble when trying to tree-shake static classes. When you import one method from a static utility class, the bundler usually brings along the entire class object just to be safe.
If that class houses twenty helpers and your file only needs one, all twenty end up in the client's download bundle anyway. ES6 modules completely sidestep this. When you export standalone functions, the bundler effortlessly snips out whatever isn't used. Swapping out static classes for flat module exports almost always trims down shared utility chunks, especially in repos that accumulate helper functions over several years.
State Shenanigans and Node.js Memory Leaks
Static properties stick around in global memory across your app's entire lifespan. On a long-running Node.js backend, that behavior turns into a genuine memory hazard if you aren't careful.
Stuffing data into a static array essentially parks that data inside Node's module cache indefinitely. The garbage collector won't touch it until the process restarts. What started as an innocent in-memory cache on a static class can quietly drain available server memory under sustained traffic. For real caching, reach for Redis, or let your framework's scoped dependency injection manage that lifecycle.
Testability: The Pain of Mocking Static Methods in Jest
Writing unit tests around code that depends on static classes can turn into a headache fast. Static methods don't play nicely with standard test mocks and have a bad habit of polluting the testing environment.
Why jest.spyOn Gets Brittle
To mock out a static method, you have to spy directly on the class. That means you're on the hook for resetting and cleaning up that spy after every single test run to prevent weird side effects.
TypeScript
import { Logger } from './logger';
test('should log error', () => {
const spy = jest.spyOn(Logger, 'error').mockImplementation(() => {});
// Run your actual logic here
spy.mockRestore(); // Miss this line and your subsequent tests go haywire
});If an assertion blows up before your code hits mockRestore(), the mock stays alive and leaks right into neighboring suites. That's how you get phantom test failures on CI that pass locally and drive everyone crazy.
The Case for Good Old Dependency Injection
Passing dependencies directly into your classes makes all of this pain disappear. When a class receives its helpers through a constructor instead of hardcoding static calls, testing is trivial. You just feed in a dummy mock object when instantiating the class in your test runner. No prototype spying, no leaky global state, and no frantic post-test cleanup required.
What Frameworks Like NestJS and Angular Think
Modern enterprise ecosystems go out of their way to steer you away from static classes. Frameworks like NestJS and Angular are built entirely around Inversion of Control containers, and static methods fight that paradigm directly.
Calling a static method inside a NestJS service completely circumvents the framework's lifecycle. You lose the ability to cleanly inject config variables, request contexts, or database connections without manually threading them through as arguments every single time. Whenever you're working inside these frameworks, do yourself a favor and wrap your logic in an @Injectable() service instead.
A Quick Audit Checklist for Your Static Classes
Before ripping things apart, run your existing static class through these five sanity checks. If you're hitting "yes" across the board, moving to a module is your best bet.
| Question to Ask | What to Do |
|---|---|
| Does your bundler need to trim down unused code here? | Move it to a module |
| Are you writing painful Jest spies to mock this class? | Move it to a module |
| Is this code running inside Angular or NestJS? | Move it to a module / service |
| Does this class store mutable static data? | Refactor to DI or external store |
| Is this utility referenced in five or more files? | Move it to a module |
If you hit a solid "no" on every single question, leaving the class alone probably won't hurt anything. A focused utility containing purely immutable helpers with zero side effects isn't going to ruin your week.
Refactoring in Practice: From Static Class to Plain Module
Ditching an old-fashioned static class in favor of modern exports is straightforward, and the resulting code is immediately cleaner and easier to reason about.
Here's the old static setup:
TypeScript
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);
}
}Here's what it looks like as a clean module:
TypeScript
export const slugify = (text: string): string => {
return text.toLowerCase().trim();
};
export const truncate = (text: string, length: number): string => {
return text.substring(0, length);
};Consumers can now grab only the specific functions they actually care about. The imports look natural, your bundle stays lean, and each helper can be tested in complete isolation. This exact pattern is what ESLint's no-extraneous-class rule is nudging you toward.
Keeping Your Codebase Clean: ESLint's no-extraneous-class
The broader TypeScript community reached a consensus on this years ago. In fact, typescript-eslint has a dedicated rule built specifically to catch this: no-extraneous-class.
When turned on, the linter will throw a flag anytime someone writes a class made up entirely of static members. It's an easy way to train the team to default to ES6 modules for utility functions rather than carrying over class-centric habits from backend languages.
JSON
{
"rules": {
"@typescript-eslint/no-extraneous-class": "error"
}
}If you're introducing this to an established codebase, set it to "warn" first so you can get a realistic look at how many files need attention. The refactoring itself is mostly mechanical, so you can easily knock it out piece by piece without breaking existing features.