Abstraction Patterns

intermediate abstraction factory-functions interface-emulation information-hiding

Abstraction is about hiding the messy details and showing only what matters. Think of it like driving a car — we use the steering wheel and pedals, but we don’t need to know how the engine combustion works internally. In OOP, abstraction means we expose a clean interface and tuck away the complexity behind it.

Why Abstraction Matters

Without abstraction, every part of our code knows about every other part’s internals. One small change breaks everything. Abstraction gives us:

  • Simpler usage — callers only see what they need
  • Easier maintenance — we can change internals without breaking consumers
  • Enforced contracts — subclasses must implement certain methods

JS Has No abstract Keyword

Languages like Java and TypeScript have abstract classes that can’t be instantiated directly and force subclasses to implement certain methods. JavaScript doesn’t have this built-in. But we can fake it.

The trick is to throw an error in the base class method. If a subclass forgets to override it, the error tells them immediately.

class PaymentProcessor {
  process(amount) {
    throw new Error("Subclass must implement process()"); // enforce the contract
  }
  refund(transactionId) {
    throw new Error("Subclass must implement refund()");
  }
}

class StripeProcessor extends PaymentProcessor {
  process(amount) {
    console.log(`Charging $${amount} via Stripe`);
  }
  refund(transactionId) {
    console.log(`Refunding ${transactionId} via Stripe`);
  }
}

If someone creates a PaypalProcessor but forgets to implement refund(), they’ll get a clear error the first time it’s called. Not perfect, but it works.

Preventing Direct Instantiation

We can also stop anyone from creating an instance of the base class directly using new.target:

class Shape {
  constructor() {
    if (new.target === Shape) {
      throw new Error("Shape is abstract — use a subclass instead");
    }
  }
  area() {
    throw new Error("Subclass must implement area()");
  }
}

class Circle extends Shape {
  constructor(radius) {
    super();
    this.radius = radius;
  }
  area() {
    return Math.PI * this.radius ** 2;
  }
}

// new Shape();        // Error: Shape is abstract
// new Circle(5).area() // 78.54

new.target tells us which constructor was actually called. If someone does new Shape() directly, new.target is Shape. If they do new Circle(5), new.target is Circle even though Shape’s constructor runs.

Factory Functions as Abstraction

Factory functions are one of the cleanest abstraction patterns in JS. The caller doesn’t know (or care) what’s happening inside — they just get an object back.

function createLogger(type) {
  if (type === "console") {
    return { log: (msg) => console.log(`[LOG] ${msg}`) };
  }
  if (type === "silent") {
    return { log: () => {} }; // does nothing, same interface
  }
  throw new Error(`Unknown logger type: ${type}`);
}

const logger = createLogger("console");
logger.log("Server started"); // [LOG] Server started

The caller just calls createLogger() and gets something with a .log() method. It doesn’t care whether it’s writing to the console, a file, or nowhere. That’s abstraction.

Symbol.species — Controlling Derived Constructors

When we extend built-in classes like Array, methods like .map() and .filter() return an instance of our subclass by default. Symbol.species lets us control this.

class TrackedArray extends Array {
  static get [Symbol.species]() {
    return Array; // map/filter should return plain Arrays, not TrackedArrays
  }
}

const tracked = new TrackedArray(1, 2, 3);
const mapped = tracked.map((x) => x * 2);

console.log(mapped instanceof TrackedArray); // false
console.log(mapped instanceof Array);        // true

This is useful when our subclass has expensive setup (like tracking or validation) that we don’t want to trigger for every intermediate array operation.

When Abstraction Helps vs When It Hurts

Abstraction is powerful, but overdoing it is a real problem. Here’s a simple rule:

  • Good abstraction — hides complexity that callers genuinely don’t need to know about
  • Bad abstraction — adds layers of indirection that make debugging harder without real benefit

If we have only one implementation of an “interface” and we’ll probably never have another — just use the concrete thing directly. We don’t need a DatabaseInterface base class if we’re only ever going to use PostgreSQL. We can always add the abstraction later when a second implementation actually shows up.

In simple language, abstraction in JavaScript is about exposing a simple surface while keeping the complex wiring hidden. We don’t have abstract classes like Java, but throwing errors in base methods and using factory functions gets us most of the way there — without overcomplicating things.