ESC

Type to search the knowledge base.

Function.prototype.call and apply Polyfill

Polyfill call and apply: set this for one invocation, spread an argument list, and explain why apply(null, array) is not the same as a spread.

intermediate5 min read
  • javascript
  • this
  • polyfill
  • interview

The first call polyfill almost everyone writes is thisArg.fn = target; thisArg.fn(a, b); delete thisArg.fn. It greets 'fn' in thisArg forever when delete fails, it clobbers an existing fn, and the target can see that extra own property while it runs. Native Function.prototype.call never installs a visible key on the receiver. The sibling trap is treating apply as fn(...list). apply takes an array-like. Spread takes an iterable. fn.apply(null, aSet) and fn(...aSet) are not the same call.

Write standalone myCall and myApply. Assigning onto Function.prototype is interview convention and mutates a shared builtin.

Key takeaways

  • call sets this for one invocation and takes a discrete argument list.
  • apply is the same invocation, with arguments read from an array-like (length plus indexes).
  • Do not leave a leftover own property on the receiver. A unique key plus delete in finally is the whiteboard fix.
  • Primitive thisArg is boxed in sloppy mode. Strict functions keep the primitive. Nullish becomes globalThis only in sloppy mode.
  • apply(null, x) is not a spread. Strings throw. Sets contribute no args. Array-likes without an iterator work.

call sets this, apply spreads the list

Internal Call(F, thisArg, argList) is the whole feature. call builds argList from the rest parameters. apply builds it with CreateListFromArrayLike: the second argument must be an object, or null / undefined (meaning no args). A string primitive is not an object, so fn.apply(null, 'ab') throws. 'ab' is iterable, so fn(...'ab') passes 'a' and 'b'.

function myCall(fn, thisArg, ...args) {
  if (typeof fn !== 'function') {
    throw new TypeError('myCall called on a non-function');
  }

  if (thisArg == null) {
    // Ordinary call: sloppy callees see globalThis, strict callees see undefined.
    // Native call(null) on a strict function yields null, not undefined.
    return fn(...args);
  }

  const context = Object(thisArg);
  const key = Symbol('myCall');
  context[key] = fn;
  try {
    return context[key](...args);
  } finally {
    delete context[key];
  }
}

function myApply(fn, thisArg, argArray) {
  if (typeof fn !== 'function') {
    throw new TypeError('myApply called on a non-function');
  }

  let args = [];
  if (argArray != null) {
    if (typeof argArray !== 'object' && typeof argArray !== 'function') {
      throw new TypeError('CreateListFromArrayLike called on non-object');
    }
    const len = argArray.length >>> 0;
    args = new Array(len);
    for (let i = 0; i < len; i++) {
      args[i] = argArray[i];
    }
  }
  return myCall(fn, thisArg, ...args);
}

function tag(prefix, suffix) {
  return prefix + this.id + suffix;
}

const node = { id: 7 };
myCall(tag, node, '<', '>');  // '<7>'
myApply(tag, node, ['#', '']); // '#7'

Symbol as the install key avoids colliding with 'fn' or 'call'. finally runs on throw, so a target that blows up does not leave the method behind. The property is still visible to Object.getOwnPropertySymbols(this) during the call. Native call does not have that leak. Say it; do not pretend the polyfill is exotic.

Object(thisArg) is the sloppy-mode box. tag.call(1) in sloppy code sees a Number object. In strict code (modules included) this stays the primitive 1. A property-attach polyfill cannot represent a primitive this without the real Call. Same gap for this === null on a strict function: attaching to null is impossible, and fn(...args) passes undefined, not null.

apply is array-like, spread is iterable

Value passed as the list myApply(fn, thisArg, value) fn(...value)
[1, 2] two args two args
{ 0: 1, 1: 2, length: 2 } two args TypeError unless it is iterable
new Set([1, 2]) zero args (length is undefined) two args
'ab' (primitive) TypeError 'a', 'b'
null / undefined zero args TypeError
arguments object works (array-like) works in ES2015+ (iterable)

That table is the answer to “why is apply(null, array) not a spread?” Null as thisArg is a separate issue: it is sloppy-global / strict-null, while fn(...array) is an ordinary call (undefined / global). The argument-list rule is the one people skip.

function argc() {
  return arguments.length;
}

myApply(argc, null, { 0: 'a', 1: 'b', length: 2 }); // 2
myApply(argc, null, new Set([1, 2, 3]));            // 0
// argc(...new Set([1, 2, 3]))                      // 3

Footguns in call and apply polyfills

A string key 'fn' overwrites thisArg.fn. Restoring the old value still means the target observed the wrong fn while it ran. Unique key, then delete.

Frozen or sealed receivers throw when you add the key. Native call works on frozen objects because it never adds one. Reflect.apply(fn, thisArg, args) is the engine primitive; using it is correct and, in a strict reading, cheating if the prompt banned call / apply / Reflect.

Huge lists: apply and spread both hit argument-count limits on old engines. Math.max.apply(null, giant) is a history question, not a reason to copy the array with a loop in user code today.

apply with a missing second argument is apply(thisArg, undefined): no args, no throw. Passing a number, a boolean, or a string throws. Do not coerce those with Object(argArray) or you accept Object(3) and read length off a Number wrapper (undefined → zero args) instead of throwing.

The bind polyfill is allowed to use apply. This page is the one that may not. Prototype assignment (Function.prototype.myCall = function (...) { return myCall(this, ...); }) mutates a builtin.

Interview answer (out loud)

“call invokes the function once with an explicit this and a rest list. apply does the same, but the arguments come from an array-like: I read length and indexes, or I take no args when the list is null or undefined. A non-object list throws. I would not assign thisArg.fn = target, because that leaves or clobbers an own property; a Symbol key and delete in finally is the usual polyfill. Primitive this is boxed in sloppy mode. apply is not spread: Sets and strings prove it.”

Further reading

Related guides