Skip to main content

How to Extend Built-In Classes in JavaScript

JavaScript allows you to extend its built-in classes like Array, Map, Set, Error, and others using the standard extends keyword. This is a powerful capability that lets you create specialized versions of native data structures with custom methods, validation logic, or domain-specific behavior while retaining all the functionality of the original.

However, extending built-in classes comes with subtleties that do not apply to regular class inheritance. Built-in methods like map, filter, and slice need to know what type of object to return. The Symbol.species mechanism controls this behavior, and static methods follow different inheritance rules. This guide covers how to extend built-ins correctly, the quirks you will encounter, and the patterns that make it practical.

Extending Array, Map, Error, and Others

Extending Array

The most common built-in to extend is Array. You might want an array with extra utility methods, validation, or event notification:

class PowerArray extends Array {
// Check if the array is empty
isEmpty() {
return this.length === 0;
}

// Get the last element
last() {
return this[this.length - 1];
}

// Get the first element
first() {
return this[0];
}

// Sum all numeric elements
sum() {
return this.reduce((total, item) => total + (typeof item === "number" ? item : 0), 0);
}

// Get unique values
unique() {
return PowerArray.from(new Set(this));
}

// Average of numeric elements
average() {
const numbers = this.filter(item => typeof item === "number");
if (numbers.length === 0) return 0;
return numbers.reduce((sum, n) => sum + n, 0) / numbers.length;
}
}

const arr = new PowerArray(1, 2, 3, 4, 5);

console.log(arr.isEmpty()); // false
console.log(arr.first()); // 1
console.log(arr.last()); // 5
console.log(arr.sum()); // 15
console.log(arr.average()); // 3

// All native Array methods still work
console.log(arr.map(x => x * 2)); // PowerArray [2, 4, 6, 8, 10]
console.log(arr.filter(x => x > 3)); // PowerArray [4, 5]
console.log(arr.includes(3)); // true
console.log(arr.indexOf(4)); // 3

// instanceof works correctly
console.log(arr instanceof PowerArray); // true
console.log(arr instanceof Array); // true
console.log(Array.isArray(arr)); // true

A key detail here is that built-in methods like map and filter return instances of the derived class (PowerArray), not plain Array instances. This means you can chain your custom methods after native ones:

const result = arr
.filter(x => x > 2) // Returns PowerArray [3, 4, 5]
.map(x => x * 10) // Returns PowerArray [30, 40, 50]
.last(); // Custom method works on the result!

console.log(result); // 50

This behavior is powered by Symbol.species, which we will cover in the next section.

A Practical Example: ValidatedArray

class TypedArray extends Array {
#type;

constructor(type, ...items) {
// Validate all initial items
for (const item of items) {
if (typeof item !== type) {
throw new TypeError(`Expected ${type}, got ${typeof item}: ${item}`);
}
}
super(...items);
this.#type = type;
}

push(...items) {
for (const item of items) {
if (typeof item !== this.#type) {
throw new TypeError(`Expected ${this.#type}, got ${typeof item}: ${item}`);
}
}
return super.push(...items);
}

unshift(...items) {
for (const item of items) {
if (typeof item !== this.#type) {
throw new TypeError(`Expected ${this.#type}, got ${typeof item}: ${item}`);
}
}
return super.unshift(...items);
}
}

const numbers = new TypedArray("number", 1, 2, 3);
numbers.push(4); // Works
console.log(numbers); // TypedArray [1, 2, 3, 4]

// numbers.push("five"); // TypeError: Expected number, got string: five

Extending Map

You can extend Map to add domain-specific features:

class DefaultMap extends Map {
#defaultFactory;

constructor(defaultFactory, entries) {
super(entries);
this.#defaultFactory = defaultFactory;
}

get(key) {
if (!this.has(key)) {
const defaultValue = this.#defaultFactory(key);
this.set(key, defaultValue);
return defaultValue;
}
return super.get(key);
}
}

// Auto-creates empty arrays for missing keys
const groupedByLetter = new DefaultMap(() => []);

const words = ["apple", "banana", "avocado", "blueberry", "cherry", "apricot"];

for (const word of words) {
groupedByLetter.get(word[0]).push(word);
}

console.log(groupedByLetter.get("a")); // ["apple", "avocado", "apricot"]
console.log(groupedByLetter.get("b")); // ["banana", "blueberry"]
console.log(groupedByLetter.get("c")); // ["cherry"]
console.log(groupedByLetter.get("z")); // [] (auto-created empty array)

Another practical Map extension for counting:

class CounterMap extends Map {
increment(key, amount = 1) {
const current = this.get(key) || 0;
this.set(key, current + amount);
return this;
}

decrement(key, amount = 1) {
return this.increment(key, -amount);
}

mostCommon(n = 1) {
const sorted = [...this.entries()].sort((a, b) => b[1] - a[1]);
return n === 1 ? sorted[0] : sorted.slice(0, n);
}

total() {
let sum = 0;
for (const count of this.values()) {
sum += count;
}
return sum;
}
}

const wordCount = new CounterMap();
const text = "the cat sat on the mat the cat";

for (const word of text.split(" ")) {
wordCount.increment(word);
}

console.log(wordCount.get("the")); // 3
console.log(wordCount.get("cat")); // 2
console.log(wordCount.mostCommon()); // ["the", 3]
console.log(wordCount.mostCommon(2)); // [["the", 3], ["cat", 2]]
console.log(wordCount.total()); // 8

Extending Set

class SuperSet extends Set {
// Mathematical set operations
union(otherSet) {
const result = new SuperSet(this);
for (const item of otherSet) {
result.add(item);
}
return result;
}

intersection(otherSet) {
const result = new SuperSet();
for (const item of this) {
if (otherSet.has(item)) {
result.add(item);
}
}
return result;
}

difference(otherSet) {
const result = new SuperSet();
for (const item of this) {
if (!otherSet.has(item)) {
result.add(item);
}
}
return result;
}

isSubsetOf(otherSet) {
for (const item of this) {
if (!otherSet.has(item)) return false;
}
return true;
}

toArray() {
return [...this];
}
}

const a = new SuperSet([1, 2, 3, 4, 5]);
const b = new SuperSet([4, 5, 6, 7, 8]);

console.log(a.union(b).toArray()); // [1, 2, 3, 4, 5, 6, 7, 8]
console.log(a.intersection(b).toArray()); // [4, 5]
console.log(a.difference(b).toArray()); // [1, 2, 3]
console.log(a.isSubsetOf(new SuperSet([1, 2, 3, 4, 5, 6]))); // true
info

Modern JavaScript (ES2025) is adding native union, intersection, difference, and other set methods to Set.prototype. Once widely available, you may not need to extend Set for these operations. Until then, extending Set is a clean solution.

Extending Error

Extending Error is one of the most common and practical uses of built-in class extension. Custom error classes help you distinguish between different types of errors in your application:

class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.name = "AppError";
this.statusCode = statusCode;
this.timestamp = new Date();
}
}

class ValidationError extends AppError {
constructor(message, field) {
super(message, 400);
this.name = "ValidationError";
this.field = field;
}
}

class NotFoundError extends AppError {
constructor(resource, id) {
super(`${resource} with id ${id} not found`, 404);
this.name = "NotFoundError";
this.resource = resource;
this.resourceId = id;
}
}

class AuthenticationError extends AppError {
constructor(message = "Authentication required") {
super(message, 401);
this.name = "AuthenticationError";
}
}

class AuthorizationError extends AppError {
constructor(message = "Insufficient permissions") {
super(message, 403);
this.name = "AuthorizationError";
}
}

// Using custom errors
function getUser(id) {
if (typeof id !== "number") {
throw new ValidationError("ID must be a number", "id");
}
if (id < 0) {
throw new ValidationError("ID must be positive", "id");
}
// Simulate user not found
throw new NotFoundError("User", id);
}

// Handling custom errors
try {
getUser(42);
} catch (error) {
if (error instanceof ValidationError) {
console.log(`Validation failed on field "${error.field}": ${error.message}`);
} else if (error instanceof NotFoundError) {
console.log(`${error.resource} #${error.resourceId}: ${error.message}`);
console.log(`Status: ${error.statusCode}`);
} else if (error instanceof AuthenticationError) {
console.log("Please log in");
} else {
console.log("Unexpected error:", error.message);
}
}
// Output:
// User #42: User with id 42 not found
// Status: 404

A key detail when extending Error: always set this.name to the class name. Without it, stack traces will show the parent class name, which makes debugging harder.

The Error.captureStackTrace Trick (V8 Engines)

In V8-based environments (Chrome, Node.js), you can use Error.captureStackTrace to clean up the stack trace so it does not include the error constructor itself:

class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;

// V8-specific: removes the constructor from the stack trace
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
}
}
}

Extending Promise

You can even extend Promise for specialized behavior:

class TimeoutPromise extends Promise {
constructor(executor, timeout) {
let timeoutId;

super((resolve, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`Promise timed out after ${timeout}ms`));
}, timeout);

executor(
(value) => {
clearTimeout(timeoutId);
resolve(value);
},
(reason) => {
clearTimeout(timeoutId);
reject(reason);
}
);
});
}
}

// Usage
const fetchWithTimeout = new TimeoutPromise((resolve, reject) => {
// Simulate a slow network request
setTimeout(() => resolve("Data received"), 5000);
}, 3000);

fetchWithTimeout
.then(data => console.log(data))
.catch(err => console.log(err.message));
// After 3 seconds: "Promise timed out after 3000ms"

Symbol.species: Controlling the Constructor of Derived Collections

When you call map, filter, slice, or other methods that create new instances on a derived array, how does JavaScript know which constructor to use? Should PowerArray.filter() return a PowerArray or a plain Array?

The answer lies in Symbol.species.

Default Behavior: Derived Type Is Returned

By default, when built-in methods need to create a new instance, they look at the constructor of the current object. So if you call .filter() on a PowerArray, you get a PowerArray back:

class PowerArray extends Array {
last() {
return this[this.length - 1];
}

isEmpty() {
return this.length === 0;
}
}

const arr = new PowerArray(1, 2, 3, 4, 5);

const filtered = arr.filter(x => x > 3);
console.log(filtered); // PowerArray [4, 5]
console.log(filtered instanceof PowerArray); // true
console.log(filtered.last()); // 5 (custom method works!)

const mapped = arr.map(x => x * 2);
console.log(mapped instanceof PowerArray); // true
console.log(mapped.last()); // 10 (custom method works!)

const sliced = arr.slice(1, 3);
console.log(sliced instanceof PowerArray); // true
console.log(sliced.isEmpty()); // false (custom method works!)

This is often exactly what you want. Derived arrays maintain their enhanced type through transformations.

When You Want Plain Arrays Back

Sometimes, the derived behavior is undesirable. Your extended class might have a constructor that requires specific arguments, making it incompatible with how map or filter create new instances. Or your custom methods might not make sense on the filtered/mapped results.

Symbol.species lets you tell built-in methods which constructor to use when creating new instances:

class PowerArray extends Array {
// Tell built-in methods to use plain Array for new instances
static get [Symbol.species]() {
return Array;
}

last() {
return this[this.length - 1];
}

isEmpty() {
return this.length === 0;
}
}

const arr = new PowerArray(1, 2, 3, 4, 5);

// Custom methods work on the original
console.log(arr.last()); // 5
console.log(arr.isEmpty()); // false

// But derived operations return plain Arrays
const filtered = arr.filter(x => x > 3);
console.log(filtered); // [4, 5] (plain Array)
console.log(filtered instanceof PowerArray); // false
console.log(filtered instanceof Array); // true

// Custom methods are NOT available on the result
// filtered.last(); // TypeError: filtered.last is not a function

How Symbol.species Works Internally

When a built-in method like Array.prototype.map needs to create a new array, it does not just call new Array(...). Instead, it roughly follows this process:

// Simplified internal logic of Array.prototype.map:
Array.prototype.map = function(callback) {
// Step 1: Check Symbol.species to determine which constructor to use
const Constructor = this.constructor[Symbol.species] || this.constructor;

// Step 2: Create a new instance using that constructor
const result = new Constructor(this.length);

// Step 3: Fill it with mapped values
for (let i = 0; i < this.length; i++) {
if (i in this) {
result[i] = callback(this[i], i, this);
}
}

return result;
};

So the lookup chain is:

  1. Check this.constructor[Symbol.species]
  2. If it exists, use that as the constructor
  3. If not, use this.constructor

Symbol.species with Map and Set

Map and Set do not use Symbol.species in the same way because their methods (like forEach, has, get) generally do not create new collections. However, extending them with methods that do create new instances can use this pattern manually:

class TrackedMap extends Map {
#accessLog = [];

get(key) {
this.#accessLog.push({ action: "get", key, time: Date.now() });
return super.get(key);
}

set(key, value) {
this.#accessLog.push({ action: "set", key, time: Date.now() });
return super.set(key, value);
}

getAccessLog() {
return [...this.#accessLog];
}

// Manually implement species-like behavior for custom methods
toPlainMap() {
return new Map(this.entries());
}
}

Symbol.species with Promise

Promise also supports Symbol.species. Methods like .then() create new promises, and Symbol.species controls which constructor they use:

class TrackedPromise extends Promise {
static get [Symbol.species]() {
return Promise; // .then() returns plain Promises, not TrackedPromises
}
}

const p = new TrackedPromise((resolve) => resolve(42));

const chained = p.then(value => value * 2);
console.log(chained instanceof TrackedPromise); // false
console.log(chained instanceof Promise); // true

When to Use Symbol.species

Use Symbol.species to return the parent class when:

  • Your subclass constructor requires specific arguments that built-in methods cannot provide
  • Your custom methods do not make sense on derived results
  • You want to avoid surprises when users chain native methods on your derived class
// Problem: constructor requires extra arguments
class LabeledArray extends Array {
#label;

constructor(label, ...items) {
super(...items);
this.#label = label;
}

getLabel() {
return this.#label;
}
}

const labeled = new LabeledArray("scores", 90, 85, 92);
console.log(labeled.getLabel()); // "scores"

// This fails because map tries to call: new LabeledArray(3)
// where 3 is the length, but constructor expects (label, ...items)
// const mapped = labeled.map(x => x + 10);
// The behavior may be unexpected

// Fix with Symbol.species:
class LabeledArrayFixed extends Array {
#label;

constructor(label, ...items) {
if (typeof label === "number" && items.length === 0) {
// Called by Array internals with just a length
super(label);
this.#label = "";
} else {
super(...items);
this.#label = label;
}
}

// Or simply use Symbol.species to avoid the issue entirely:
static get [Symbol.species]() {
return Array;
}

getLabel() {
return this.#label;
}
}

const fixed = new LabeledArrayFixed("scores", 90, 85, 92);
const mapped = fixed.map(x => x + 10); // Returns plain Array (no constructor confusion)
console.log(mapped); // [100, 95, 102]
tip

If your extended class has a constructor with a different signature than the parent, always define Symbol.species to return the parent class. Otherwise, built-in methods that create new instances will call your constructor with unexpected arguments, causing subtle bugs.

No Static Inheritance from Built-Ins

When you extend a regular class, static methods are inherited through the constructor prototype chain. However, when you extend a built-in class, static methods of the built-in are not inherited in the same reliable way.

Regular Class: Statics Are Inherited

class Parent {
static greet() {
return "Hello from Parent";
}
}

class Child extends Parent {}

console.log(Child.greet()); // "Hello from Parent" (inherited)
console.log(Object.getPrototypeOf(Child) === Parent); // true

Built-In Class: Static Inheritance Is Limited

class PowerArray extends Array {}

// Some Array statics exist on PowerArray, but the prototype chain is different
console.log(Object.getPrototypeOf(PowerArray) === Array); // true (chain exists)

// Array.isArray works (it's a standalone function, checks internal slots)
console.log(Array.isArray(new PowerArray())); // true

// Array.from works when called on PowerArray
const pa = PowerArray.from([1, 2, 3]);
console.log(pa instanceof PowerArray); // true
console.log(pa instanceof Array); // true

// Array.of also works
const pa2 = PowerArray.of(4, 5, 6);
console.log(pa2 instanceof PowerArray); // true

At first glance, it seems like static inheritance works fine with built-ins. And in modern engines, it mostly does for Array, Map, Set, etc. The key historical issue is with Object:

class MyObject extends Object {
constructor(data) {
super();
Object.assign(this, data);
}
}

// Object static methods like Object.keys are NOT accessed through inheritance
// They are used directly on Object, not on MyObject
console.log(Object.getPrototypeOf(MyObject) === Object); // true

// But Object.keys(myInstance) works regardless: it's called on Object, not MyObject
const obj = new MyObject({ a: 1, b: 2 });
console.log(Object.keys(obj)); // ["a", "b"]

The Real Difference

The important distinction is between two types of statics:

  1. Statics that create instances (like Array.from, Array.of): These use this internally, so they respect the derived class and create instances of the correct type.

  2. Statics that are pure utilities (like Object.keys, Object.assign, Array.isArray): These are typically called on the base class directly and do not use this to determine the return type.

class PowerArray extends Array {
last() { return this[this.length - 1]; }
}

// Category 1: Instance-creating statics, respect the derived class
const fromArr = PowerArray.from([1, 2, 3]);
console.log(fromArr instanceof PowerArray); // true
console.log(fromArr.last()); // 3

const ofArr = PowerArray.of(4, 5, 6);
console.log(ofArr instanceof PowerArray); // true
console.log(ofArr.last()); // 6

// Category 2: Utility statics, always called on the base class
console.log(Array.isArray(fromArr)); // true (called on Array, not PowerArray)

Practical Implications

For most real-world usage, the limitation around static inheritance from built-ins is not a problem:

class EnhancedMap extends Map {
static fromObject(obj) {
// Your own static methods work perfectly
return new this(Object.entries(obj));
}

static merge(...maps) {
const result = new this();
for (const map of maps) {
for (const [key, value] of map) {
result.set(key, value);
}
}
return result;
}
}

const map1 = EnhancedMap.fromObject({ a: 1, b: 2 });
const map2 = EnhancedMap.fromObject({ c: 3, d: 4 });
const merged = EnhancedMap.merge(map1, map2);

console.log(merged instanceof EnhancedMap); // true
console.log(merged.size); // 4

Your own static methods on derived classes work perfectly. The limitation only affects inheriting static methods from the built-in parent, and in practice, built-in static utility methods are rarely called through the subclass anyway.

Why the Limitation Exists

Built-in classes in JavaScript are implemented natively in the engine (in C++ for V8, for example). Their static methods are not regular JavaScript functions defined on a prototype. The ECMAScript specification treats built-in constructors specially, and the prototype chain between constructors is set up differently for built-ins than for user-defined classes.

The specification explicitly states that for built-in classes like Array, Map, Set, etc., the [[Prototype]] of the constructor is set to the parent built-in's constructor (e.g., Array.__proto__ === Function.prototype by default, but extends changes it). However, some engines may optimize or handle these chains differently for built-in constructors.

info

In modern JavaScript engines (2020+), static inheritance from built-ins generally works through the prototype chain. The issue was more prominent in older environments and transpiled code (Babel). If you are targeting modern environments, static methods on built-in parents are usually accessible on derived classes. However, it is still good practice to call utility statics directly on the base class (Array.isArray, Object.keys) rather than relying on inheritance.

Best Practices for Extending Built-Ins

Do Extend Built-Ins When:

  • You need a specialized collection with domain-specific methods
  • You are creating custom error types for your application
  • The extension is genuinely "is-a" relationship (a PowerArray is an Array)

Do Not Extend Built-Ins When:

  • You only need a few utility functions (use standalone functions or a wrapper class instead)
  • Your constructor has a different signature than the parent (will cause issues with species)
  • You need to override core behavior extensively (composition is usually better)

Composition as an Alternative

Sometimes wrapping a built-in is cleaner than extending it:

// Extension approach (can be fragile)
class FilteredList extends Array {
#predicate;

constructor(predicate, ...items) {
super(...items.filter(predicate));
this.#predicate = predicate;
}

// Need Symbol.species because constructor signature differs
static get [Symbol.species]() { return Array; }

push(...items) {
const valid = items.filter(this.#predicate);
return super.push(...valid);
}
}

// Composition approach (often simpler and more robust)
class FilteredList2 {
#items = [];
#predicate;

constructor(predicate, items = []) {
this.#predicate = predicate;
this.#items = items.filter(predicate);
}

add(...items) {
const valid = items.filter(this.#predicate);
this.#items.push(...valid);
return this;
}

remove(index) {
this.#items.splice(index, 1);
return this;
}

toArray() {
return [...this.#items];
}

get length() {
return this.#items.length;
}

[Symbol.iterator]() {
return this.#items[Symbol.iterator]();
}
}

// Composition version
const positiveNumbers = new FilteredList2(n => n > 0, [1, -2, 3, -4, 5]);
positiveNumbers.add(6, -7, 8);

console.log(positiveNumbers.toArray()); // [1, 3, 5, 6, 8]
console.log(positiveNumbers.length); // 5

for (const n of positiveNumbers) {
console.log(n); // 1, 3, 5, 6, 8
}

Summary

ConceptKey Takeaway
Extending built-insUse extends with Array, Map, Set, Error, Promise, etc. to add custom methods
Instance type preservationBuilt-in methods like map, filter, slice return instances of the derived class by default
Symbol.speciesStatic getter that tells built-in methods which constructor to use for new instances
Return parent typeSet static get [Symbol.species]() { return ParentClass; } when derived results should be plain instances
Constructor signatureIf your constructor differs from the parent, use Symbol.species to avoid incompatible constructor calls
Custom errorsExtending Error is the most common and practical built-in extension; always set this.name
Static inheritanceStatic methods from built-in parents are generally accessible but utility statics should be called on the base class directly
Composition vs. extensionPrefer composition when the relationship is "has-a" or when the constructor signature differs significantly

Extending built-in classes is a powerful tool in JavaScript that lets you build domain-specific data structures while leveraging the full functionality of native types. The key is understanding Symbol.species for controlling derived instance types and knowing when composition would serve you better than inheritance.