DEV Community

Cover image for Do you need classes in JS/TS? 2025 version
András Tóth
András Tóth

Posted on • Edited on

1

Do you need classes in JS/TS? 2025 version

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 the stack
  • some way to deal with memory when you have a lot of items in your stack, 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 internal state 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 internal state 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 those public accessors from the implementation, declaring mandatory methods and properties of a stack, 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 {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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 contain methods => this is POD aka plain-old-data, you need a simple object: {} (+ a type in TypeScript)
  • any method of the class that does not seriously use this, can be moved into their own functions; by "seriously" I mean it doesn't use this 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 using class cohesion: if you have a disjunct set of methods and variables they can go into their own class.

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 === '';
}

Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
  };
}
Enter fullscreen mode Exit fullscreen mode

For more information check out his excellent answer! https://dev.to/peerreynders/comment/1m04m

Top comments (0)

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more

👋 Kindness is contagious

Engage with a wealth of insights in this thoughtful article, valued within the supportive DEV Community. Coders of every background are welcome to join in and add to our collective wisdom.

A sincere "thank you" often brightens someone’s day. Share your gratitude in the comments below!

On DEV, the act of sharing knowledge eases our journey and fortifies our community ties. Found value in this? A quick thank you to the author can make a significant impact.

Okay