Hi there folks!
This is a revised version of 2022 article: Do you need classes in JS/TS?. (Ethical AI usage notice: no content was generated for the article or the cover image, however I did ask for a grammar check).
If you are a fresh JavaScript/TypeScript developer, you'll encounter some very vocal opinions on writing code in a "functional style" (FP, or "functional programming") or an "objectied oriented style" (OOP, or "object oriented programming").
You will hear devs saying things like "serious backend work requires a strong command of object oriented patterns", or _"On the frontend you must avoid side effects and embrace functional programming!".
Not coincidentally you will encounter backend frameworks that are fully object-oriented (like NestJS) and frontend ones that embrace immutability and pure functions, like the framework redux
.
Personally, I believe that the right technique should be used for the right problem.
The key sign of misusing a particular style is confusion.
If you start to feel confused about why such a simple thing as adding a new endpoint to a web service or handling a simple HTML form requires so many steps or files, there is a chance that the wrong paradigm was applied.
Or more likely, black and white thinking about a particular paradigm resulted in bloat, unnecessarily complicating code.
For the sake of brevity, this article focuses on the "object oriented" paradigm: when to use it in JavaScript/TypeScript working on the frontend and webservices. I exclude niche applications like parsers/linters/transpilers/etc. and games.
The point of using a class
Let's roll back time and go back to an old language: C
. The algorithm you are working on requires an implementation of a stack and you have to write one from scratch. You drop elements into the stack, and by using the pop()
function, you retrieve the last element that was dropped into it.
To implement it you would need the following things:
- some kind of storage that holds the
items
of thestack
- some way to deal with memory when you have a lot of
items
in yourstack
, even expanding the storage behind the scenes if it fills up - you want to keep a clear front, so the user access only specific methods and properties, giving you room to refactor later
Therefore you will have the following:
- a
private
internalstate
that holds values between calls (the stack stays in memory and you can access at your leisure) - a couple of internal
methods
that manages your internalstate
properly and a way to hide them from users to avoid code coupled to the implementation of the day - on the other hand you do want to show
public
methods that lets you interact with the unit - and optionally have an
interface
to further decouple thosepublic
accessors from the implementation, declaring mandatory methods and properties of astack
, allowing you to interchange different implementations if needed
Now imagine when this concept was new
and trending! It was very elegant, very organized. People back then were dealing mostly with desktop applications or very low level data structures like buffers, pointers, etc. and it made sense to tuck all that complexity away and use higher organization by having more of that class
thing.
The rise of Object Oriented Programming
As with any trends, the industry got a bit overboard with it. When C# and Java arrived it was no longer possible to just declare a procedure
or function
without having a class; in my opinion it was a way to discourage people from Procedural Programming which was the old paradigm and force people to learn this new way of organizing code. (Not always successfully - I had a Java developer colleague who didn't bother with classes: everything was a Map<string, something>
, essentially reinventing other dynamic languages 😄.)
I get what they wanted but this paradigm was overly complicating dead simple things, like a humble data transformation code where you process data from type A
to type B
and where you do not need to keep an internal state made of low level building blocks.
Therefore, while OOP solved great many important problems, it also tighly coupled classes
with the idea of "well organized code". This became ingrained in a generation who uses it, well, for everything.
Now let's see a pair of contrasting examples to hit my point home.
Good example: database connection
Managing access to a database usually requires maintaining some kind of connection pool (reusing a number of connections to a database server). It also needs to track if we have already logged in or not, if the remote server is down and so on. We don't want to know about all of that, just a way to configure DB access and to run queries. These functions will constitute our interface
: a set of functions and properties that do not change frequently (or at all preferably) while keeping the implementation details of the connection and the connection pool hidden (making them private
). We do want to also persist some state
as well to track, say, if we already gained access to the DB or not yet, or how much of the connection pool is already used up.
// An overly simplified database module
interface Database {
configure: (options: ConfigOptions) => void;
connect: (user: string, password: string) => void;
query<T>: (query: string) => T;
// I just make this up for a simple example
isReadyToServeRequests: boolean;
}
class PostgresDatabase implements Database {
// ...
}
// Same public methods but maybe very different concrete implementation!
class MySqlDatabase implements Database {
// ...
}
Bad example: REST handlers
Let's say you have a dynamic FAQ page on your company website helping users. They want to click on articles in their own language with tailored, relevant information. This can be understood as a simple projection of (userId, articleId, language) => article
. A simple function with its isolated scope ("the closure") is ideal here since you don’t want to persist state between calls: the article of user A
is unrelated to the article of user B
.
I have a little story about this. About 6 years ago we were new to next.js when we ran into this problem - in fact 2 teams separately made the same mistake: persisting a state
between calls unwittingly. The bug we ran into with my team was serving articles in a random language, depending on who called last - if 2 users were making concurrent calls and had different languages the articles showed up all mixed. We accidentally stored language
in a local state
- the request handler was finding this state
and used that.
The other team had it worse: unlike us they didn't catch it in QA and they managed to deploy a bug to production where users could see other users' private data...
The solution was to load up the user
in both cases and scope it to the request handler, which became a simple function. Now each call was isolated from the other.
Summing it up, there was no need to keep a state
between calls, whereas the interface
was also very simple: 1 endpoint matching 1 function. (In real life you might need to create sub functions as you don't want to have 1000 lines functions.)
Conclusion of the examples
As I hopefully demonstrated, while OOP is an excellent pattern for certain problems, it is not a magic bullet and can add a useless complexity and even be dangerous when used on the wrong thing.
I am positive that seeing more examples can help you grasp this:
- If you build a game, all the enemies are little persisted states with things like
health
, abilities etc. Perfect sense to go with classes! ✅ - If you implement data structures, components with internal states, complicated algorithms that run for a long time and need to keep tab on a lot of little variables: classes to use! ✅
- Data transformations, projections: you have no internal state, classes are a bad fit! ❌
- API request handlers: you might have millions of concurrent requests, each of them should be isolated from each other: having an internal
state
is actually dangerous here, OOP is ❌! (A database service you may need to call is not an internal state.)
Understanding the clash between OOP and JavaScript's language design
The tension between Java/C# style object-oriented programming and JavaScript lies in the fact that
Javascript (and consequentially TypeScript) is neither strictly functional nor an object oriented language.
(Note: TypeScript is not making JS into an object-oriented language! I heard this baffling myth a couple of times from people.)
It has elements of both but its implementation differs in very important aspects. For example if you used JS for a while you must have encountered curious problems around this
. You passed a method
of a class
to a function
as a callback
and BOOM! 💥, this.something
was no longer there. In a classic OOP language, this can never happen because classes
"own" their methods
. In JavaScript classes
are just syntactic sugar on top of this object
-function
soup we have. There is no such thing as grabbing an object's method
and binding
it to another object's internal state
in Java or C#.
One particular anti-pattern I see comes directly from C#/Java where it was unavoidable to use class
even when just needed to define a function
or to store a couple of properties. In JavaScript, I consider this a design mistake as we can create simple functions
and simple objects
without the bells and whistles.
On the other hand, JavaScript isn't a true functional language either, as it lacks key guarantees like the immutability of function arguments and functions can have side-effects. (Though, I wish we could declare and enforce functions to be pure
!).
A classic example is the .sort()
method of an array
which modifies the original instance unlike .map
which isn't (note that in 2025 we have now .toSorted()
that does no longer mutate the array! 🎉). This means if you pass the same array to two different functions if the implementation of functionA
sorts the array, it will change what is happening in functionB
!
(A personal rant: I can't fathom to understand why it is next to impossible to find in the documentation of Jest how to mock named exports of functions. It is clear to me that Jest was designed for OOP in JS and supporting proper JS import/export patterns was an afterthought.)
When to refactor a class
Now that we understand the strengths and weaknesses of a class
let's have some simple rules about when and how to dismantle them:
- the
class
does not containmethods
=> this is POD aka plain-old-data, you need a simple object:{}
(+ atype
inTypeScript
) - any
method
of theclass
that does not seriously usethis
, can be moved into their ownfunctions
; by "seriously" I mean it doesn't usethis
to avoid receiving arguments (see below) - now check what's left of the
class
; if the resulting methods don't need this anymore, move them out
At this point, either your class
is gone or it has a tightly knit set of variables
and methods
.
👆 There's also a chance here to split large
classes
into smaller ones, by usingclass
cohesion: if you have a disjunct set ofmethods
andvariables
they can go into their ownclass
.
The true feature a class
provides is access to this
in its methods. When some or all of them do not access it, as they operate over the arguments passed in then you will need a function
instead. I am going to show you an example next where we seem to use this
properly but we are better off with using the simplest functions.
Example 1: Function args as constructor parameter
export class StringValidator {
constructor(private input: string | null | undefined) {
}
sanitizeString(): string {
if (this.input === undefined || this.input === null) {
return '';
}
return this.input.trim().replace(/\s+/g, ' ');
}
isEmpty(): boolean {
return this.input === undefined || this.input === null || this.input === '';
}
}
// As you can see, the class has no real internal state
// We just use the constructor to pass in an argument.
// Moreover, the cohesion of the class is really low;
// none of the methods depend on each other.
// Instead you can refactor this to two pure functions:
export function sanitizeString(input: string | undefined | null): string {
if (input === undefined || input === null) {
return '';
}
return input.trim().replace(/\s+/g, ' ');
}
export function isEmptyString(input: string | undefined | null): boolean {
return input === undefined || input === null || input === '';
}
Example 2: saving a pure function from the swamp
async function transformDataAndSendEmail(recipient: string, someData: SomeData) {
const mailDocument = formatSomeData(someData);
// why use a class for something that will be thrown away?
await (new MailService()).sendMail(recipient, mailDocument);
}
// In this case, "MailService" is not a real service.
// It is not "waiting somewhere in the memory" to handle requests.
// Nor does it have some meaningful internal state.
// It is just an extra step to get to `sendMail()` function call.
// And it will be garbage collected when we are out of `transformDataAndSendEmail` block.
Conclusion
Working with JavaScript we should use JavaScript patterns. That would yield us the simplest results.
For getting the "JavaScript way" I really recommend the books of getify
You don't know JS; class alternatives.
Questions, agree or disagree? Sharing your edge cases help both of us refining our points of views. 🤝 Let me know in the comments!
Note
As a commenter on my first article pointed out, you don't even need to use the class
keyword, just use any old function to create a new object:
function makeSomething() {
let myPrivateMember = 13;
function getMyNumber() {
return myPrivateMember;
}
function randomizeMyNumber(range) {
myPrivateMember = Math.floor(Math.random() * range);
}
return {
getMyNumber,
randomizeMyNumber
};
}
For more information check out his excellent answer! https://dev.to/peerreynders/comment/1m04m
Top comments (0)