Introduction
Throughout my career, many engineers with 20+ YoE always told me: "Read the JavaScript specification." Early in my career, opening the ECMAScript spec felt like reading legal code written for compiler authors—dense, foreign, and overwhelming. But over time, learning to parse the spec became one of the most effective ways to truly understand how JavaScript executes under the hood.
This post is the first in a series breaking down the fundamentals of ECMAScript specifications by building our own polyfills directly from the official algorithm. We will start with a method every JavaScript developer uses daily: Array.prototype.push().
By the end of this tutorial, you will know:
- How to navigate and read your very first method from the ECMAScript spec.
- How to map abstract operations to real JavaScript code.
- Why
push()is generic and works on objects that aren't arrays.
The Spec "Decoder Ring"
Before jumping into reading the spec and building our own version of Array.prototype.push(), let's cover some of the syntax and terminology that TC39 uses across the specification:
| Spec Notation | Meaning in Plain JavaScript |
|---|---|
? Operation() | Return if abrupt: Run the operation. If it throws an error, immediately rethrow/propagate that error. |
! Operation() | Assert success: This operation is guaranteed by invariants never to fail or throw an error under these conditions. |
𝔽(number) | Number conversion: Converts an internal mathematical real number into an ECMAScript double-precision 64-bit float. |
ToObject(val) | Wraps primitives into their object counterparts (Object(val)); throws a TypeError if given null or undefined. |
The Official Specification
According to the ECMAScript Specification for Array.prototype.push, when we use Array.prototype.push() these steps occur:
- Let O be ?
ToObject(this value). - Let len be ?
LengthOfArrayLike(O). - Let argCount be the number of elements in items.
- If len + argCount > $2^{53} - 1$, throw a TypeError exception.
- For each element E of items, do:
a. Perform ?
Set(O, !ToString(𝔽(len)), E, true). b. Set len to len + 1. - Perform ?
Set(O,"length", 𝔽(len), true). - Return 𝔽(len).
Let's translate this specification into a custom method called austinPush (fancy naming, i know 😎).
Building austinPush(): Step-by-Step
This code snippet will be our reference for understanding how push() works.
const months = ['Jan', 'Mar', 'Apr', 'May'];
months.push('Feb');
console.log(months); // Array ['Jan', 'Mar', 'Apr', 'May', 'Feb']
Step 1: Coerce this to an Object
Spec: 1. Let O be ?
ToObject(this value).
- This is saying: "Get the object (Array) that the
push()method is being called on."- Refrencing the code example above,
monthsisthis value
- Refrencing the code example above,
- The
?is used for error handling. IfToObject(this value)throws an error, the function will throw an error.- If
monthswasnullorundefined, this method,push(), would throw an error.
- If
ToObjectis an abstract operation that convertsthis valueto an object. It is used to ensure that the object that the method is called on is an object.Object(months)would return the objectmonths.
function austinPush(...items) {
// 1. Let O be ? ToObject(this value).
let O = Object(this);
// ...
}
Step 2: Determine Current Length
Spec: 2. Let len be ?
LengthOfArrayLike(O).
- This is saying: "Get the object (Array) that the
push()method is being called on."- Refrencing the code example above,
monthsisthis value
- Refrencing the code example above,
- The
?is used for error handling. IfToObject(this value)throws an error, the function will throw an error.- If
monthswasnullorundefined, this method,push(), would throw an error.
- If
ToObjectis an abstract operation that convertsthis valueto an object. It is used to ensure that the object that the method is called on is an object.Object(months)would return the objectmonths.
Notice that the spec does not check if O is an array. It only checks LengthOfArrayLike(O). This operation reads the length property of O, coerces it into a non-negative integer, and caps it at $2^{53} - 1$.
Let's write a small helper that mirrors LengthOfArrayLike(O):
const toLength = (value) => {
const len = Number(value);
if (Number.isNaN(len) || len <= 0) return 0;
return Math.min(Math.floor(len), Number.MAX_SAFE_INTEGER);
};
function austinPush(...items) {
// 1. Let O be ? ToObject(this value).
let O = Object(this);
// 2. Let len be ? LengthOfArrayLike(O).
let len = toLength(O.length);
// ...
}
Step 3 & 4: Count Arguments and Check Safe Limits
Spec: 3. Let argCount be the number of elements in items.
- If len + argCount > $2^{53} - 1$, throw a TypeError exception.
itemsis the array of arguments that are passed to thepush()method.argCountis the number of arguments that are passed to thepush()method(austinPush()for us).lenis the current length of the object. It is calculated usingtoLength()which is a helper function that mirrorsLengthOfArrayLike(O).$2^{53} - 1$JavaScript represents numbers using 64-bit binary floating-point values (IEEE 754). Integers beyond $2^{53} - 1$ (Number.MAX_SAFE_INTEGER) cannot be uniquely represented. If adding your arguments would cause the array length to exceed that limit, the engine aborts immediately with aTypeError.
const toLength = (value) => {
const len = Number(value);
if (Number.isNaN(len) || len <= 0) return 0;
return Math.min(Math.floor(len), Number.MAX_SAFE_INTEGER);
};
function austinPush(...items) {
// 1. Let O be ? ToObject(this value).
let O = Object(this);
// 2. Let len be ? LengthOfArrayLike(O).
let len = toLength(O.length);
// 3. Let argCount be the number of elements in items.
let argCount = items.length
// 4. If len + argCount > 2****53 - 1, throw a TypeError exception.
if ((len + argCount) > (2 ** 53 - 1)) {
throw new TypeError('Operation could not be performed, exceeded max safe integer in JavaScript');
}
// ...
}
Step 5: Append Elements to the Object
Spec: 5. For each element E of items, do:
a. Perform ?
Set(O, !ToString(𝔽(len)), E, true).b. Set len to len + 1.
Step 5:
- At this point we're creating a foreach loop and running it for each element inside of items. We take the first element and add it to the end of the object.
Eis an element of the array of arguments.itemsis the array of arguments that are passed to thepush()method.lenis the current length of the object.
Step 5a:
- This the point where we append each element to the end of the array. if
monthsis the array and we are pushing'Feb'into it, then we are adding the element'Feb'to the end of the arraymonths. This is where that happens Setis an abstract operation that sets the value of the property! ToString(𝔽(len))toEon the objectO.! ToString(𝔽(len))is the property name that is used to store the element in the array.Eis the element that is being added to the array.trueis a boolean value that indicates whether the property should be set.?is used for error handling. IfSet(O, ! ToString(𝔽(len)), E, true)throws an error, the function will throw an error.
Step 5b:
- This is incrementing the length of the array by 1, because we are adding a new element to the array.
lenis the length of the array.len + 1is the new length of the array.- This will increment the length of the array by 1.
const toLength = (value) => {
const len = Number(value);
if (Number.isNaN(len) || len <= 0) return 0;
return Math.min(Math.floor(len), Number.MAX_SAFE_INTEGER);
};
function austinPush(...items) {
// 1. Let O be ? ToObject(this value).
let O = Object(this);
// 2. Let len be ? LengthOfArrayLike(O).
let len = toLength(O.length);
// 3. Let argCount be the number of elements in items.
let argCount = items.length
// 4. If len + argCount > 2****53 - 1, throw a TypeError exception.
if ((len + argCount) > (2 ** 53 - 1)) {
throw new TypeError('Operation could not be performed, exceeded max safe integer in JavaScript');
}
// 5. For each element E of items, do
items.forEach((E) => {
// a. Perform ? Set(O, ! ToString(𝔽(len)), E, true).
O[len] = E;
// b. Set len to len + 1.
len = len + 1;
})
// ...
}
Step 6 & 7: Commit New Length & Return
Spec: 6. Perform ?
Set(O,"length", 𝔽(len), true).
- Return 𝔽(len).
Step 6
- This is updating the length property of the array to the new length.
?is used for error handling. IfSet(O, "length", 𝔽(len), true)throws an error, the function will throw an error.Set(O, "length", 𝔽(len), true)is an abstract operation that sets the value of the property"length"to𝔽(len)on the objectO."length"is the property name that is used to store the length of the array.𝔽(len)is the value of the length of the array.trueis a boolean value that indicates whether the property should be set.
Step 7
- This is returning the new length of the array.
𝔽(len)is the value of the length of the array.
A common beginner slip-up is assuming push() returns the modified array. The spec shows that it explicitly writes the final count to "length" on O and returns that numeric count.
// Step 6: Update the length property on O
O.length = len;
// Step 7: Return the new length
return len;
The Complete Implementation
Here is our complete spec-compliant implementation, attached to Array.prototype for educational exploration:
const toLength = (value) => {
const len = Number(value);
if (Number.isNaN(len) || len <= 0) return 0;
return Math.min(Math.floor(len), Number.MAX_SAFE_INTEGER);
};
function austinPush(...items) {
// 1. Let O be ? ToObject(this value).
if (this == null) {
throw new TypeError('Array.prototype.austinPush called on null or undefined');
}
const O = Object(this);
// 2. Let len be ? LengthOfArrayLike(O).
let len = toLength(O.length);
// 3. Let argCount be the number of elements in items.
const argCount = items.length;
// 4. If len + argCount > 2**53 - 1, throw a TypeError exception.
if (len + argCount > Number.MAX_SAFE_INTEGER) {
throw new TypeError('Exceeded maximum safe array length');
}
// 5. For each element E of items, do
for (const element of items) {
// a. Perform ? Set(O, ! ToString(𝔽(len)), E, true).
O[len] = element;
// b. Set len to len + 1.
len += 1;
}
// 6. Perform ? Set(O, "length", 𝔽(len), true).
O.length = len;
// 7. Return 𝔽(len).
return len;
}
// Attach custom implementation to prototype for testing
Array.prototype.austinPush = austinPush;
(Note: Monkey patching built-in prototypes in production applications is discouraged. It's used here to study native behavior.)
Verifying Our Implementation
Run this test suite in your console to verify that our polyfill handles typical usages, edge cases, and generic bindings:
// Test 1: Standard push and return value
const months = ['Jan', 'Mar', 'Apr', 'May'];
const resultLength = months.austinPush('Feb');
console.assert(resultLength === 5, `Expected 5, got ${resultLength}`);
console.assert(months[4] === 'Feb', 'Element not inserted at index 4');
// Test 2: Appending multiple arguments
const numbers = [1, 2];
numbers.austinPush(3, 4, 5);
console.assert(numbers.length === 5, `Expected length 5, got ${numbers.length}`);
console.assert(numbers[4] === 5, 'Element not inserted at index 4');
// Test 4: Null / Undefined check
try {
Array.prototype.austinPush.call(null, 'fail');
console.assert(false, 'Should have thrown TypeError');
} catch (err) {
console.assert(err instanceof TypeError, 'Expected TypeError on null context');
}
console.log('All tests passed successfully!');
Conclusion
Reading the ECMAScript specification might seem intimidating initially, but every built-in method follows the same predictable pattern: input coercion, validation, state mutation, and result return. Breaking it down into executable code turns abstract standards into clear mental models. Hopefully this helped!
If you want to explore the official specification directly, check out tc39.es/ecma262 and if you like this follow me on social media and message me. I'd be happy to do more tutorials like this!