bnjmn.

extending objects in js

2025-02-09

I’ll be honest - I haven’t had too much practice with “class-based” JS. I started learning Javascript, and React by extension, at a time when class components were on their way out. But! I’ve been doing a deep dive into Ruby, and classes are pervasive in Rubyland.

In Rubyland, (almost) everything’s an object. Objects are great because they have methods, which are useful. And if an object doesn’t have the functionality we want, we can add it easily:

class Car
  def initialize(name)
   @name = name
  end
end

# reopen it somewhere else...
class Car
  def describe()
    puts "Car(name=#{@name})"
  end
end

This is called monkeypatching, and its a practice that’s generally frowned upon. Wikipedia talks about some of the pitfalls of monkeypatching.

With that disclaimer out of the way - can we do something similar in Javascript? Well, yes, and its really easy too:

String.prototype.isEven = function () {
  return this.length % 2 === 0;
};
"this".isEven();

But what if we don’t want to monkeypatch a class? What options do we have in Javascript?

We can extend the original class:

class MyArr extends Array {
  double() {
    return this.map((x) => x * 2);
  }
}

const myArr = new MyArr(1, 2, 3, 4);
myArr
  .double()
  .map((x) => x - 1)
  .filter((x) => x === 1)
  .double();

Calling .map and .filter still returns an instance of MyArr, so we can still call double at the end - it’s all good.

Or, we can simply add a function as a property on the object:

function Arr(...args) {
  let ret = Array.from(args);
  ret.each = {
    do(cb) {
      ret.forEach(cb);
    },
  };
  return ret;
}

let arr = Arr(1, 2, 3, 4, 5);
arr.each.do((x) => console.log(x));
console.log(arr);
arr = arr.map((x) => x + 3);
console.log(arr); // we lost `each` here

However, this won’t really work for our purposes. Whenever we call a native array method, we lose our newly-defined method.

We can also use Object.assign - but, we run into the same issue:

function AssignArr(...args) {
  const ret = Array.from(args);
  const meths = {
    has(thing) {
      return this.includes(thing);
    },
    each: {
      do(cb) {
        ret.forEach(cb);
      },
    },
  };
  return Object.assign(ret, meths);
}

let assignedArr = AssignArr(1, 2, 3);
console.log(assignedArr.has(2));
assignedArr = assignedArr.map((x) => x * 2);
assignedArr.has(2);

Same with Object.create:

function createArr(...args) {
  return Object.create(Array.from(args), {
    getAtIdx: {
      value(idx) {
        return this[idx];
      },
    },
    has: {
      value(thing) {
        return this.includes(thing);
      },
    },
  });
}

let createdArr = createArr(1, 2, 3, 4, 5);
createdArr = createdArr.map((x) => x * 2);
console.log(createdArr.has(1)); // error here

Or, use a Proxy:

class Handler {
  has(arg) {
    return this.includes(arg);
  }
}

const handler = new Handler();
const proxiedArr = new Proxy([], {
  get(target, property) {
    return target[property] || handler[property];
  },
});

proxiedArr.push(2);
proxiedArr.push(102);
console.log(proxiedArr.has(102));
const proxiedArr1 = proxiedArr.filter((x) => x === 102);
proxiedArr1.has(2); // proxiedArr1.has is not a function

It turns out, there aren’t a lot of ways to do this.

Practically speaking:

  • We probably don’t want to mess with the prototype
  • Extending the class is probably the only viable way if we want to preserve the methods we’ve defined

Some references: