The Only JavaScript Article You'd Ever Need - 6/6

The Challenge of Organizing Complex Logic

You’ve built powerful tools—recreated .map(), mastered Promise.all, and created utility functions like debounce to optimize performance. You’ve developed strong functional programming skills, which is impressive progress.

But as your applications grow more complex, you’ll encounter a fundamental organizational challenge.

Currently, your code likely consists of well-crafted but separate functions, with data stored in loose objects or variables that get passed between these functions. This approach works well for simple transformations, but becomes unwieldy when managing complex entities.

Think of it this way: you have excellent tools (functions) and quality materials (data), but they exist in separate spaces. Every time you need to perform operations, you must coordinate between these separate concerns. While this works for simple scenarios, it becomes inefficient and error-prone as complexity increases.

The real challenge emerges when you’re not just processing simple arrays, but managing complex entities like users, products, shopping carts, or game characters. These aren’t just data points—they’re entities with both state (data) and behavior (methods) that belong together.

Let’s explore how functional approaches can become difficult to maintain as complexity grows.

Let’s Manage Some “Users”

Imagine we’re building the next billion-dollar social media app for sad pets. We need to manage users. A user has a username, an email, and a password. They can log in, log out, and change their password.

Attempt #1: Pure Functional Approach

Your first instinct, having mastered functional programming, is to keep data and logic separate. After all, “separation of concerns” is a fundamental programming principle.

// A simple function to create a user data object
function createUser(username, email, password) {
  return {
    username: username,
    email: email,
    password: password, // Storing passwords in plaintext, because we're not PCI compliant
    isLoggedIn: false,
  };
}

// A bunch of loose functions to OPERATE on a user object
function logInUser(user) {
  user.isLoggedIn = true;
  console.log(`${user.username} has logged in.`);
  return user;
}

function changeUserPassword(user, newPassword) {
  user.password = newPassword;
  console.log(`Password for ${user.username} has been changed.`);
  return user;
}

// Let's try to use this mess
const userOne = createUser("SadCat123", "cat@meow.com", "password123");
const userTwo = createUser("DepressedDoggo", "dog@woof.com", "anotherpassword");

logInUser(userOne);
changeUserPassword(userTwo, "ihatefetch");

console.log(userOne); // { username: 'SadCat123', ..., isLoggedIn: true }
console.log(userTwo); // { username: 'DepressedDoggo', ..., password: 'ihatefetch' }

This approach works perfectly for small scripts. However, as your application scales, you’ll encounter several challenges.

Imagine expanding this to include dozens of functions: deleteUser, updateUserProfile, sendFriendRequestToUser, blockUser, validateUser, and so on.

The problems that emerge:

  1. Tight coupling to data structure: Every function expects a very specific data “shape.” Adding a new property to the user object requires examining every function that might be affected.

  2. No enforced relationship: The data (user object) and behavior (logInUser function) exist independently. There’s no guarantee they’ll remain compatible as both evolve.

  3. Type safety concerns: You could accidentally pass a product object into logInUser, and the error might not surface until runtime.

  4. Maintenance overhead: As the codebase grows, tracking which functions work with which data structures becomes increasingly difficult.

This functional approach works well for simple transformations, but becomes challenging to maintain as complexity increases.

Attempt #2: Factory Functions

Recognizing the limitations of separate functions and data, the next logical step is to bundle them together. Functions and their associated data have a natural relationship—it makes sense to organize them as a unit.

A factory function is a function that isn’t a constructor (no new keyword needed), but it returns a new object. We can put the functions inside the object.

function createUserFactory(username, email, password) {
  const user = {
    username: username,
    email: email,
    password: password,
    isLoggedIn: false,
  };

  // Attach the behavior directly to the object
  user.logIn = function () {
    user.isLoggedIn = true;
    console.log(`${user.username} has logged in.`);
  };

  user.changePassword = function (newPassword) {
    user.password = newPassword;
    console.log(`Password for ${user.username} has been changed.`);
  };

  return user;
}

const userThree = createUserFactory(
  "AnxiousAxolotl",
  "axl@water.net",
  "bubbles"
);
userThree.logIn(); // Look, ma! The method is on the object!
userThree.changePassword("morebubbles");

This is a significant improvement. The data and behavior are now packaged together. You call .logIn() directly on the user object itself. It’s intuitive and feels natural.

So what’s the problem? Memory inefficiency.

Every time you call createUserFactory, you create a brand new copy of the logIn function and the changePassword function. If you create 10,000 users, you’re storing 10,000 identical copies of those functions in memory. This represents a substantial waste of resources, as the logic is identical for every user.

This approach solves the organization problem but introduces a significant performance concern. We’re duplicating behavior that should be shared across all instances.

The Real Solution: Stop Copying, Start Sharing

The smart way to solve this is to have one copy of the methods (logIn, changePassword) and have every user object share it. This is the core idea of prototypal inheritance, the bedrock on which modern JS objects are built.

The old way of doing this involved manually manipulating an object’s prototype. It was powerful, but the syntax was hideous and confusing for newcomers. It looked like this:

// The old, ugly way. Don't actually do this.
function UserProto(username, email) {
  this.username = username;
  this.email = email;
}

UserProto.prototype.login = function () {
  console.log(`${this.username} logged in.`);
};

const userFour = new UserProto("ConfusedCapybara", "capy@bara.org");
userFour.login();

This works and solves the memory problem. The login method exists in only one place (UserProto.prototype), and userFour has a reference to it. However, the syntax feels complex and unintuitive. The prototype and new keyword mechanics can be confusing, especially for developers coming from other languages.

Enter class: Modern Object-Oriented Syntax

The JavaScript standards committee recognized these usability issues. They saw developers struggling with prototype syntax and introduced the class keyword to provide a cleaner, more intuitive way to work with object-oriented patterns.

Important note: JavaScript class is primarily syntactic sugar. Under the hood, it still uses the same prototype-based inheritance. However, it provides a much cleaner, more readable syntax that makes object-oriented programming more accessible and maintainable.

The class syntax gives us a clear, standardized way to create blueprints for objects that bundle data and shared behavior together.


Classes: Building Blueprints for Your Objects

A class is a blueprint. It’s not the house; it’s the architectural drawing of the house. It defines what properties (data) and methods (behavior) all houses built from this blueprint will have.

Let’s dissect the blueprint for our User.

class User {
  // 1. The Constructor: The special setup function
  constructor(username, email, password) {
    // 3. Properties: Data unique to each instance
    this.username = username;
    this.email = email;
    this.password = password;
    this.isLoggedIn = false;
  }

  // 4. Methods: Shared behavior for all instances
  logIn() {
    this.isLoggedIn = true;
    console.log(`${this.username} has logged in.`);
  }

  changePassword(newPassword) {
    this.password = newPassword;
    console.log(`Password for ${this.username} has been changed.`);
  }
}

This represents a significant improvement in code organization. It’s clean, logical, and addresses all our previous concerns. Let’s examine each component systematically.

1. The class Keyword

class User { ... } This keyword declares that we are defining a blueprint named User. By convention, class names start with a capital letter to distinguish them from regular functions and variables.

2. The constructor() Method

This is the most important method in any class. It’s a special function that is automatically called every single time you create a new object from the class (which we call an instance).

Its job is to set up the object. It takes arguments (like username, email) and assigns them as properties to the new object.

3. Properties and the this Keyword

Inside the constructor, you see this.username = username;. The this keyword, in the context of a class constructor, refers to the new, empty object that is being created. You are literally saying, “On this new object we’re making, create a property called username and set its value to the username that was passed into the constructor.”

These properties (this.username, this.email, etc.) are the unique data for each instance. Every user will have their own username, but they will all be built from this same structure.

4. Methods

logIn() { ... } Any function you define directly inside the class block (that isn’t the constructor) is a method. This is the behavior. The magic here is that JavaScript automatically places these methods on the prototype. This means there is only one copy of logIn and changePassword, and every User instance you create will share it. The memory problem is solved, elegantly.

Inside these methods, this refers to the specific instance the method was called on. When you call userFive.logIn(), this inside logIn will be userFive.

Bringing Your Blueprint to Life: The new Keyword

A class is just a blueprint. It doesn’t do anything on its own. To actually use it, you need to construct an object from it. You do this with the new keyword.

// 5. Creating an Instance
const userFive = new User("ProcrastinatingPanda", "panda@bamboo.com", "sleepy");
const userSix = new User("ScreamingGoat", "goat@mountain.yell", "loudnoises");

// Now, let's use them
userFive.logIn(); // "ProcrastinatingPanda has logged in."
userSix.changePassword("evenloudernoises");

console.log(userFive.isLoggedIn); // true
console.log(userSix.password); // "evenloudernoises"

When you write new User(...), four things happen in sequence:

  1. A new, empty object {} is created.
  2. This new object’s internal prototype is linked to User.prototype, so it can access the shared methods (logIn, changePassword).
  3. The constructor method is called, with this set to be the new empty object. The properties are assigned.
  4. The newly created object is returned and assigned to the variable (userFive).

The Four Pillars of Object-Oriented Programming

The four pillars of OOP are fundamental design principles that help you create maintainable, scalable, and robust object-oriented code. These aren’t abstract concepts—they’re practical guidelines that prevent well-designed classes from becoming unmaintainable as applications grow.

5.1. Encapsulation: Controlling Access to Data

The Problem: You’ve built a well-designed User class with proper methods for updating user data. However, without proper access controls, other developers might bypass your carefully designed methods and directly modify properties: user.password = '123';. This bypasses validation, hashing logic, and other important safeguards.

The Solution: Encapsulation means controlling which parts of your object are accessible from outside code. You, as the class designer, decide what’s public (like username) and what should remain private (like passwordHash or lastLoginIP). This principle bundles data with the methods that operate on it while controlling access to both.

Historically, JavaScript lacked true privacy mechanisms. The community adopted conventions like prefixing properties with underscores (_password) to signal “private” properties, but this was merely a gentlemen’s agreement with no enforcement.

Modern JavaScript provides true privacy mechanisms.

Private Class Fields (ES2019+): True Privacy

Modern JavaScript provides genuine privacy controls using the hashtag (#) prefix.

class User {
  // This is a private field. The # makes it so.
  #password;

  constructor(username, email, password) {
    this.username = username;
    this.email = email;
    // We're setting the private field inside the class.
    this.#password = this.#hashPassword(password);
  }

  // This is a private method.
  #hashPassword(password) {
    // Imagine some complex, secure hashing logic here.
    return `hashed_${password}_lol`;
  }

  checkPassword(password) {
    return this.#hashPassword(password) === this.#password;
  }
}

const user = new User("SecureSam", "sam@secu.re", "supersecret");

// Let's try to be Chad from marketing.
console.log(user.username); // 'SecureSam' -> This is public, no problem.
console.log(user.#password); // SyntaxError: Private field '#password' must be declared in an enclosing class

It throws an error. A real, bona fide error. You cannot access or modify #password from outside the User class. Period. It’s a “Keep Out” sign that’s attached to an electric fence.

Getters and Setters: The Polite Receptionists

So how do you interact with data that’s private (or data you want to control)? You create public-facing “receptionists”: getters and setters. They look like properties but are actually methods, giving you a chance to run logic before reading or writing a value.

class User {
  #email;

  constructor(email) {
    // Use the setter during construction to validate!
    this.email = email;
  }

  // A 'getter' to publicly expose the email
  get email() {
    return this.#email;
  }

  // A 'setter' to protect the email property
  set email(newEmail) {
    if (!newEmail.includes("@")) {
      throw new Error("Invalid email address.");
    }
    this.#email = newEmail;
  }
}

const user = new User("test@example.com");
console.log(user.email); // 'test@example.com' (Calls the get method)

user.email = "new.email@provider.com"; // (Calls the set method)
console.log(user.email); // 'new.email@provider.com'

// Now let's try to set an invalid email
try {
  user.email = "nope"; // Throws an error!
} catch (e) {
  console.error(e.message); // 'Invalid email address.'
}

Why Encapsulation Matters: It creates a stable and predictable interface. You’re establishing clear boundaries for how other developers (and your future self) should interact with your objects. This prevents accidental corruption of object state and makes your components more robust and maintainable.

5.2. Inheritance: Code Reuse Through Hierarchical Relationships

The Problem: You have a well-designed User class. Now, the business requirements include an Admin user. An Admin can do everything a User can do, plus additional administrative functions like banning other users. The naive approach would be to copy the entire User class, rename it to Admin, and add the new methods. This creates a maintenance nightmare—every bug fix in User must be duplicated in Admin.

The Solution: Use inheritance to avoid code duplication. If an Admin is-a specialized type of User, express this relationship directly in code. Have the Admin class inherit all properties and methods from the User class automatically. This is Inheritance.

class Admin extends User {
  constructor(username, email, password, permissionsLevel) {
    // 1. Call the parent's constructor first
    super(username, email, password);

    // 2. Add the new, Admin-specific property
    this.permissionsLevel = permissionsLevel;
  }

  // 3. Add a new, Admin-specific method
  banUser(otherUser) {
    console.log(`${this.username} has banned ${otherUser.username}.`);
  }

  // 4. Override an existing method
  logIn() {
    // You can call the parent's method if you want...
    super.logIn();
    // ...and then add your own special sauce.
    console.log("Admin-level privileges activated.");
  }
}

const admin = new Admin("BossMan", "boss@corp.com", "iamthelaw", "MAXIMUM");

admin.logIn();
// "BossMan has logged in."
// "Admin-level privileges activated."

admin.checkPassword("iamthelaw"); // This method was inherited from User!
admin.banUser(user); // This method is unique to Admin.

Let’s examine the key inheritance concepts:

  • extends User: This declares that Admin is a child class of User. It inherits all of User’s public and protected properties and methods automatically.
  • super(): Inside a child class’s constructor, super() calls the parent class’s constructor. You must call this before using the this keyword. This ensures the parent class is properly initialized before the child class adds its own functionality.

Why Inheritance Matters: It provides code reuse and establishes clear hierarchical relationships (Admin is a specialized type of User), making your codebase easier to understand and maintain. However, be cautious with deep inheritance chains—classes that extend classes that extend classes create tight coupling and complex dependencies. Keep inheritance hierarchies shallow and focused for better maintainability.

5.3. Polymorphism: One Interface, Many Forms

The Problem: You have User, Admin, and Guest classes that all need to display greetings in the UI. Each class has a getGreeting() method with different implementations: User returns “Hello, Bob,” Guest returns “Hello, Guest,” and Admin returns “Greetings, Supreme Overlord.” How do you write code that can handle all of them without complex conditional logic?

// The code you want to AVOID
if (user instanceof Admin) {
  console.log("Greetings, Supreme Overlord");
} else if (user instanceof User) {
  console.log(`Hello, ${user.username}`);
} else {
  console.log("Hello, Guest");
}

This is brittle. Every time you add a new user type, you have to modify this disgusting block of code.

The Solution: Trust your objects to know what to do. As long as they all share the same method name (getGreeting), you can treat them all the same. You just call user.getGreeting() and let the object itself figure out the correct implementation at runtime. This is Polymorphism (a fancy Greek word for “many shapes”).

JS Implementation: It’s less about a specific keyword and more about a design pattern.

class Guest {
  getGreeting() {
    return "Hello, Guest. Please sign up.";
  }
}

// Assume User and Admin classes exist as before, each with their own getGreeting method.

const userList = [
  new User("NormalNancy", "n@n.com", "pass"),
  new Admin("AdminAndy", "a@a.com", "adminpass"),
  new Guest(),
];

// The polymorphic magic loop
userList.forEach((user) => {
  // We don't care what type of user it is.
  // We just trust that it has a .getGreeting() method.
  console.log(user.getGreeting());
});

// OUTPUT:
// "Hello, NormalNancy"
// "Greetings, Supreme Overlord AdminAndy"
// "Hello, Guest. Please sign up."

We called the exact same method, getGreeting(), on three different types of objects and got three different behaviors. That’s it. That’s polymorphism. The key is that method overriding (from inheritance) allows child classes to provide their specific “shape” for a common method.

Why Polymorphism Matters: It makes your code incredibly flexible and extensible. When business requirements introduce a new Moderator user type, you simply create the Moderator class, implement its getGreeting method, and add it to existing collections. The processing code doesn’t need to change at all.

5.4. Abstraction: Simplifying Complex Interfaces

The Problem: Developers using your User class don’t need to understand the internal complexity of how checkPassword() works. They shouldn’t see the intricate details of hashing, salting, and database operations. They only need a simple interface: pass a password and get back true or false. Exposing internal helper methods and complex logic creates confusion and invites misuse.

The Solution: Hide the complexity. Expose a simple, high-level interface and keep the messy implementation details hidden away. This is Abstraction. It’s the “black box” principle. You provide inputs, you get outputs, and the convoluted machinery inside is none of your business.

  • Encapsulation is your primary tool for abstraction. By making methods and properties private with #, you are literally hiding the implementation.
  • You design your class to have a clean public API (a set of public methods).
class UserAPI {
  #apiToken = "super-secret-token";

  // Public Method: The simple "interface"
  async changeEmail(newEmail) {
    if (!this.#isValidOnServer(newEmail)) {
      throw new Error("Email is already taken or invalid.");
    }
    await this.#sendUpdateRequestToDatabase({ email: newEmail });
    console.log("Email updated successfully.");
  }

  // Private Method: The complex, hidden implementation
  async #isValidOnServer(email) {
    // Imagine complex fetch() call here...
    console.log(`Checking if ${email} is valid on the server...`);
    return !email.startsWith("banned");
  }

  // Private Method: More hidden guts
  async #sendUpdateRequestToDatabase(data) {
    console.log("Sending secure request with token", this.#apiToken);
    console.log("Updating database with:", data);
    // More complex logic...
  }
}

const api = new UserAPI();
// All the user has to do is this one simple thing:
api.changeEmail("my.new@email.com");

// They cannot do this:
// api.#sendUpdateRequestToDatabase(...); // SyntaxError

Why Abstraction Matters: It dramatically simplifies the use of your complex objects. It allows you to completely change the internal logic (e.g., switch from one database to another) without breaking the code of anyone who uses your class, as long as the public method signature stays the same. It reduces the mental overhead for other developers, making your code easier to work with and harder to break.


Advanced Object-Oriented Concepts

Now that you understand the four pillars, let’s explore some advanced OOP concepts that add power and flexibility to your object-oriented designs.

  • static Methods and Properties: Sometimes you have a function or a piece of data that’s related to a class, but you don’t need a specific instance of the class to use it. These belong to the class itself, the blueprint.

    class UserUtils {
      static PI = 3.14159; // static property
    
      static isValidEmail(email) {
        // static method
        return email.includes("@");
      }
    }
    
    // Notice: you call it on the CLASS, not an instance.
    console.log(UserUtils.isValidEmail("test@test.com")); // true
    console.log(UserUtils.PI); // 3.14159
    
    const util = new UserUtils();
    // util.isValidEmail(); // TypeError: util.isValidEmail is not a function
    

    Think of them as utility functions that are namespaced under the class.

  • instanceof: This operator checks if an object is an instance of a particular class. It’s how you can peek behind the curtain of polymorphism.

    const admin = new Admin("a", "b", "c");
    console.log(admin instanceof Admin); // true
    console.log(admin instanceof User); // true (because Admin extends User)
    console.log(admin instanceof Guest); // false
    

    It’s useful, but if you find yourself writing a lot of if (x instanceof Y) blocks, you’re probably fighting against polymorphism and should rethink your design.

  • Composition Over Inheritance (The Holy War): This is the debate that fuels a thousand angry blog posts.

    • Inheritance is an is-a relationship (Admin is-a User).
    • Composition is a has-a relationship (User has-a Profile).

    The Problem with Deep Inheritance: It’s rigid. What if you want a SuperAdmin that has all the powers of an Admin, but also some features from a Guest? You can’t inherit from both. You’re trapped.

    The Composition Solution: Instead of inheriting, you compose your object from other, smaller objects.

    // Instead of inheriting features, we give our user "capabilities"
    const canBan = {
      ban() {
        console.log("BANNED!");
      },
    };
    
    const canPostArticles = {
      post() {
        console.log("Article posted.");
      },
    };
    
    function createModerator(username) {
      return {
        username,
        ...canBan,
        ...canPostArticles,
      };
    }
    
    const mod = createModerator("Moddy");
    mod.ban(); // BANNED!
    mod.post(); // Article posted.
    

    This approach is far more flexible. It’s like building with LEGO blocks (composition) instead of carving a statue from a single, unyielding block of marble (inheritance). Most modern, large-scale applications favor composition because of this flexibility.


When to Use OOP vs Functional Programming

You now have powerful object-oriented tools, but it’s important to apply them appropriately. Not every problem requires an object-oriented solution.

  • OOP is not a universal solution. It’s a tool with specific strengths and appropriate use cases.
  • When OOP Excels: In large, complex applications where you’re modeling real-world entities (users, products, transactions). When multiple developers need to collaborate on a predictable, stable codebase with clear boundaries and interfaces.
  • When Functional Approaches Are Better: For small scripts focused on data transformation, functional programming (.map, .filter, .reduce) is often cleaner and more direct. For simple utilities and one-off calculations, a straightforward function is more appropriate.
  • Quality Over Paradigm: Poorly designed object-oriented code can be just as problematic as poorly structured procedural code. Thoughtful design matters more than blindly applying any particular paradigm.

In modern JavaScript development, you’ll encounter these concepts everywhere. React components encapsulate state and behavior using class-like patterns (whether with actual classes or hooks). Backend services in Node.js frequently use classes to model database entities and business logic.

Conclusion: From Theory to Practice

You’ve learned the theoretical foundations of object-oriented programming in JavaScript. Now comes the crucial part: practical application. Consider taking one of your existing projects and refactoring loose functions and variables into well-designed classes. Experience the challenges and benefits firsthand.

The goal isn’t to memorize syntax—it’s to develop the intuition for when and how to organize code effectively using object-oriented principles.

See you around, developer :)