Skip to content

When Should You Use JavaScript Classes Over Functions?

The Problem

I was writing JavaScript code and kept seeing tutorials use classes everywhere. React components, Node.js services, utility modules - all wrapped in classes. But I also saw plenty of code that just used plain functions.

I got confused. Why add complexity with classes? Don’t functions handle everything?

I saw this in a Reddit thread:

“JS wasn’t originally designed around classes. Classes were added later mainly for cleaner syntax and familiarity for developers coming from OOP languages. You don’t need classes in JavaScript, they’re just a structured way to work with prototypes.”

So which is it? Should I use classes or functions?

What I Thought First

I started with functions because they’re simpler:

functions.js
function createUser(name, email) {
return {
name: name,
email: email,
getName() { return this.name; },
getEmail() { return this.email; }
};
}
function createAdmin(name, email, permissions) {
const user = createUser(name, email);
return {
...user,
permissions: permissions,
hasPermission(perm) { return this.permissions.includes(perm); }
};
}
const admin = createAdmin('Alice', '[email protected]', ['read', 'write']);
console.log(admin.getName()); // "Alice"
console.log(admin.hasPermission('read')); // true

This works fine for small scripts. But when I tried to check what type of object I had, I ran into problems:

problem.js
console.log(admin instanceof Admin); // ReferenceError: Admin is not defined
console.log(admin instanceof User); // ReferenceError: User is not defined
console.log(admin.constructor.name); // "Object" - not helpful

The factory function approach doesn’t give me type checking. In a large codebase, this becomes painful. I couldn’t reliably answer: “Is this object an Admin? Can I call hasPermission on it?”

What JavaScript Classes Actually Do

I realized that JavaScript classes are just syntactical sugar over prototypes. When I write:

class_sugar.js
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
getName() { return this.name; }
getEmail() { return this.email; }
}

JavaScript actually does this under the hood:

prototype.js
function User(name, email) {
this.name = name;
this.email = email;
}
User.prototype.getName = function() { return this.name; };
User.prototype.getEmail = function() { return this.email; };

The class syntax is cleaner and more familiar for developers coming from Java, C#, or Python. But it’s the same mechanism.

When Classes Actually Help

1. Runtime Type Checking with instanceof

The biggest practical benefit I found was type checking:

instanceof.js
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
getName() { return this.name; }
getEmail() { return this.email; }
}
class Admin extends User {
constructor(name, email, permissions) {
super(name, email);
this.permissions = permissions;
}
hasPermission(perm) {
return this.permissions.includes(perm);
}
}
const admin = new Admin('Alice', '[email protected]', ['read', 'write']);
console.log(admin instanceof Admin); // true
console.log(admin instanceof User); // true
console.log(admin instanceof Object); // true

This lets me write defensive code:

defensive.js
function deleteUser(user) {
if (!(user instanceof Admin)) {
throw new Error('Only admins can delete users');
}
// ... delete logic
}

2. Clear Inheritance Chain

With classes, inheritance is explicit:

inheritance.js
class Animal {
constructor(name) {
this.name = name;
}
speak() {
console.log(`${this.name} makes a sound`);
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name);
this.breed = breed;
}
speak() {
console.log(`${this.name} barks!`);
}
fetch() {
console.log(`${this.name} fetches the ball`);
}
}
const dog = new Dog('Buddy', 'Golden Retriever');
dog.speak(); // "Buddy barks!"
dog.fetch(); // "Buddy fetches the ball"

The extends keyword makes the relationship clear. With factory functions, I had to manually compose objects, which got messy.

3. Required for Some Frameworks

This is where I hit a wall. I tried to create a custom element:

custom_element.js
// This WORKS
class MyButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<button>
<slot></slot>
</button>
`;
}
}
customElements.define('my-button', MyButton);
// This DOES NOT WORK - custom elements require classes
const MyButton = {
// ... factory function approach fails here
};

Custom Web Components require classes. React class components (legacy but still used) require classes. Some Node.js frameworks expect classes.

4. Better IDE Support

I noticed my IDE handles classes better:

ide_support.js
class UserService {
constructor(apiClient) {
this.apiClient = apiClient;
this.cache = new Map();
}
async getUser(id) {
if (this.cache.has(id)) {
return this.cache.get(id);
}
const user = await this.apiClient.fetch(`/users/${id}`);
this.cache.set(id, user);
return user;
}
async updateUser(id, data) {
const user = await this.apiClient.fetch(`/users/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
});
this.cache.set(id, user);
return user;
}
}

My IDE (VS Code) can:

  • Show all methods when I type userService.
  • Jump to method definitions
  • Refactor method names across files
  • Show parameter hints

With factory functions spread across files, I lose some of this.

When Functions Are Better

I learned that classes aren’t always the answer. Here’s where I stick with functions:

Pure Utility Functions

utils.js
// RIGHT: Simple function for simple task
function formatDate(date) {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
// WRONG: Class for simple task
class DateFormatter {
format(date) {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
}

No state, no inheritance - just a simple transformation. A function is cleaner.

Functional Programming Patterns

When I want immutability and composition:

functional.js
// Function composition
const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x);
const double = x => x * 2;
const addOne = x => x + 1;
const square = x => x * x;
const calculate = pipe(double, addOne, square);
console.log(calculate(3)); // ((3 * 2) + 1) ^ 2 = 49

Classes don’t fit this pattern well.

React Hooks

Modern React uses functions:

react_hooks.jsx
// RIGHT: Functional component with hooks
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUser(userId).then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <div>Loading...</div>;
return <div>{user.name}</div>;
}
// WRONG (legacy): Class component
class UserProfile extends React.Component {
// ... more boilerplate
}

My Decision Framework

Now I use this mental model:

Use classes when:

  • I need instanceof checks
  • I have shared state across multiple methods
  • I’m building a domain model (User, Product, Order)
  • Framework requires it (Web Components, some ORMs)
  • I have a clear inheritance hierarchy

Use functions when:

  • Simple input-output transformations
  • Pure utility operations
  • Functional programming style
  • React functional components
  • No state to track

A Real Example: API Client

Here’s how I refactored my API client code.

Before (mixed approach - confusing):

api_before.js
// Factory function but acting like a class
function createApiClient(baseUrl) {
let token = null;
return {
setToken(t) { token = t; },
async get(path) {
return fetch(baseUrl + path, {
headers: { 'Authorization': `Bearer ${token}` }
});
},
async post(path, data) {
return fetch(baseUrl + path, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
}
};
}

Problem: No instanceof check, shared state hidden in closure.

After (clean class approach):

api_after.js
class ApiClient {
constructor(baseUrl) {
this.baseUrl = baseUrl;
this.token = null;
}
setToken(token) {
this.token = token;
}
async get(path) {
return fetch(this.baseUrl + path, {
headers: this.#getHeaders()
});
}
async post(path, data) {
return fetch(this.baseUrl + path, {
method: 'POST',
headers: this.#getHeaders(),
body: JSON.stringify(data)
});
}
// Private method
#getHeaders() {
const headers = { 'Content-Type': 'application/json' };
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
return headers;
}
}
// Usage
const client = new ApiClient('https://api.example.com');
client.setToken('my-secret-token');
const data = await client.get('/users');

Now I get:

  • instanceof ApiClient checks
  • Clear shared state (this.token)
  • Private methods (#getHeaders)
  • IDE autocomplete

Common Mistakes I Made

Mistake 1: Classes for Everything

wrong_class.js
// WRONG: Class for simple utility
class StringUtils {
static capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
static lowercase(str) {
return str.toLowerCase();
}
}
const result = StringUtils.capitalize('hello');
// RIGHT: Simple function
function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
const result = capitalize('hello');

Mistake 2: Deep Inheritance Hierarchies

bad_inheritance.js
// WRONG: Deep hierarchy
class Animal {}
class Mammal extends Animal {}
class Dog extends Mammal {}
class GoldenRetriever extends Dog {}
class ServiceGoldenRetriever extends GoldenRetriever {}
// RIGHT: Composition or shallow inheritance
class Animal {
constructor(traits = {}) {
this.traits = traits;
}
hasTrait(trait) {
return this.traits.includes(trait);
}
}

Deep inheritance is a smell. Prefer composition over inheritance.

Mistake 3: Ignoring Prototypes

Even with classes, understanding prototypes helps debug:

prototype_debug.js
class User {
constructor(name) {
this.name = name;
}
greet() {
return `Hello, ${this.name}`;
}
}
const user = new User('Alice');
// These are equivalent
console.log(user.greet()); // "Hello, Alice"
console.log(User.prototype.greet.call(user)); // "Hello, Alice"
// Understanding this helps when debugging
console.log(user.hasOwnProperty('name')); // true (own property)
console.log(user.hasOwnProperty('greet')); // false (on prototype)

Summary

JavaScript classes provide structure, readability, and organization for complex applications. But they’re optional - they’re syntactical sugar over prototypes.

I use classes when:

  • I need instanceof for runtime type checking
  • Multiple methods share the same state
  • Frameworks require it (Web Components, some ORMs)
  • The domain model has clear entities (User, Product, Order)

I use functions when:

  • Simple input-output transformations
  • Pure utility operations
  • Functional programming style
  • No shared state

The key insight from that Reddit thread was right: “You don’t need classes in JavaScript, they’re just a structured way to work with prototypes.” But structure matters in larger codebases, and classes provide exactly that.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments