Extending built-ins & instanceof
Subclass Array and Map safely, control species constructors, predict instanceof, and customize instanceof with Symbol.hasInstance.
- 01Extend built-ins safelyMake your own Array and Map subclasses without changing the built-ins for everyone.
- 02Choose what map and filter give backUse Symbol.species to make map and filter give back plain arrays.
- 03Predict instanceofExplain how instanceof walks the prototype chain, and how Symbol.hasInstance can replace that walk.
The idea: your own version of a built-in
JavaScript comes with ready-made tools called built-ins. Array and Map are two of them. An Array keeps items in order and knows its length. A Map stores values under keys, and a key can be any value.
Sometimes you want one of these tools with a few extra methods. You can write your own class that starts from the built-in. You do it with extends, like class NumberList extends Array. Your new class is called a subclass. The built-in it starts from is called the base class.
An earlier lesson warned you not to change built-in prototypes. A prototype is the shared object where all arrays find their methods. If you add Array.prototype.sum, every array in the program gets sum, even arrays in other people's code. A subclass is different. Only the lists you make with new NumberList() get the new method.
push(), because both are arrays. Only the NumberList also has sum(). Plain arrays stay as they were.Every student wears the same school uniform. You sew your name badge onto yours. It is still the school uniform, but now it has your badge. The other students' uniforms don't get your badge.
- In real life: The standard school uniform
- In JavaScript: What every
ArrayorMapcan already do - In real life: Your name badge, sewn on
- In JavaScript: The methods you add in your subclass
- In real life: Other students' uniforms stay the same
- In JavaScript: Other arrays and maps don't change
Extending a built-in means writing a class that starts from a built-in, such as Array or Map. Your objects keep everything the built-in can do. You can add new methods, or replace an old method with your own version.
This lesson covers four tools that work together:
- Extending
ArrayandMap. Symbol.species, which decides what kind of list methods likemapgive back.instanceof, which asks "was this value made from this class?"Symbol.hasInstance, which lets a class change howinstanceofanswers.
Custom errors use the same idea. They get their own lesson later: Throwing & custom errors.
Extending Array & Map
INTERACTIVEA subclass of Array is still a real array. All of these still work:
- Numbered places, like
list[0]. length, which grows and shrinks by itself.- Methods like
push,pop, andmap. - Loops like
for...of.
On top of that, you add methods with names that fit your program. Here is the same idea with a Map. A Map holds pairs: a key and its value.
class ScoreMap extends Map {
add(name, points) {
this.set(name, (this.get(name) ?? 0) + points);
}
}
const scores = new ScoreMap();
scores.add("Asha", 3);
console.log(scores.get("Asha"));Line 1 makes ScoreMap, a subclass of Map. Lines 2 to 4 add one new method, add. It does three things:
- It reads the old score with
this.get(name). - If there is no score yet,
?? 0uses 0 instead. The??sign picks the right side when the left side isnullorundefined. - It adds the new points and saves the total with
this.set.
Line 7 gives Asha 3 points, so line 8 prints 3.
class TodoList extends Array { d = new Set(); add(t) { this.push(t); return this.length; } complete(t) { this.d.add(t); } open() { return this.filter(Boolean); }} const todos = new TodoList("a", "b");todos.add("c");todos.complete("b");const open = todos.open();const up = todos.map(String);console.log(todos.length);console.log(open instanceof TodoList);console.log(up.constructor.name);- tasks[plan, build]
- length2
- map gives back aTodoList
- up instanceof TodoListtrue
There is no species getter. So filter and map make new TodoList lists.
Try the playground. The TodoList class has its own add and complete methods. It still grows with push, shrinks with pop, and knows its length, because it is still an array. A subclass makes sense when your object really is a list that needs a few extra methods.
Map subclasses are often simpler, because a map has fewer methods than an array. The next example changes one thing. When a key is missing, get gives back a default value instead of undefined.
Look at line 3, super(entries). Calling super(...) runs the base class's constructor, here the Map constructor. It sets up the real map storage and adds the starting entries. Only after that can the subclass save its own fallback value on line 4. On line 7, super.has and super.get call Map's own versions of those methods.
Watch a Map subclass keep the map storage and change just one method.
script
class DefaultMap extends Map { constructor(fallback, entries) { super(entries); this.fallback = fallback; } get(key) { return super.has(key) ? super.get(key) : this.fallback; }} console.log(scores.get("Ada"));console.log(scores.get("Lin"));console.log(scores instanceof Map);Symbol.species: what kind of list do map and filter make?
STEP THROUGHSome array methods make a new list. todos.map(...) doesn't change todos. It builds a second list and gives it back. For a subclass, JavaScript has to choose: should the new list be your subclass, or a plain array?
JavaScript decides with a special property called Symbol.species. A symbol is a unique value that can be used as a property name. Symbol.species is one that JavaScript itself looks for.
class Tags extends Array {}
const tags = new Tags("js", "css");
const upper = tags.map((tag) => tag.toUpperCase());
const short = tags.filter((tag) => tag.length === 2);
console.log(upper instanceof Tags);
console.log(short instanceof Tags);Line 1 makes Tags, an empty subclass of Array. Line 2 makes a Tags list with "js" and "css". Line 3 makes a new list of the tags in capital letters. Line 4 makes a new list of only the tags that are 2 letters long. Lines 5 and 6 both print true. By default, the new lists are Tags too.
A tailor copies your uniform. By default, the copy comes out in your style, badge and all. If you leave a note saying "use the standard uniform", the copy comes out plain. Symbol.species is that note.
- In real life: The tailor makes a new uniform from yours
- In JavaScript:
maporfiltermakes a new list - In real life: It comes out in your style, badge and all
- In JavaScript: By default, the new list is your subclass
- In real life: A note says: use the standard uniform
- In JavaScript:
static get [Symbol.species]() { return Array; }
Predict whether map gives back a TodoList or an Array. Then step through the real run.
script
class TodoList extends Array { d = new Set(); add(t) { this.push(t); return this.length; } complete(t) { this.d.add(t); } open() { return this.filter(Boolean); }} todos.add("c");todos.complete("b");const open = todos.open();const up = todos.map(String);console.log(todos.length);console.log(open instanceof TodoList);console.log(up.constructor.name);To get plain arrays back, add this line inside the class: static get [Symbol.species]() { return Array; }. Here is what each part means:
staticputs it on the class itself, not on each list.getmakes it a getter: a method that runs when someone reads the property.[Symbol.species]is the property's name.- It returns
Array, so new lists come out as plain arrays.
map() always makes a new list. Symbol.species decides what kind of list it is.Which one should you pick? Ask whether the new lists need your extra methods. If they do, keep the default. If they should be plain data, return Array.
Symbol.species doesn't matter for methods that change the list itself, like push or sort. Those change the original list. They never make a new one.
instanceof follows the prototype chain
STEP THROUGHvalue instanceof Thing asks: "Was value made from Thing?" It gives back true or false. It doesn't check names. It follows a chain of objects instead.
Every object has a link to another object, called its prototype. That object has its own link, and so on. This line of links is the prototype chain. instanceof walks along the chain and looks for Thing.prototype. If it finds it, the answer is true. If the chain ends first, the answer is false.
instanceof walks down the chain and stops when it finds Collection.prototype.Step through the walk along the prototype chain that instanceof does.
script
class Collection {}class Stack extends Collection {} console.log(stack instanceof Stack);console.log(stack instanceof Collection);console.log(stack instanceof Object);console.log(3 instanceof Number);Object.setPrototypeOf(stack, Collection.prototype);console.log(stack instanceof Stack);console.log(stack instanceof Collection);This rule explains three surprises:
- An object made by a subclass also counts as its base class.
stackis aStack, and it is also aCollection. - A primitive is a simple value like
3or"hi". It is not an object, so it has no chain. That is why3 instanceof Numberisfalse. - If an object's chain changes, later answers change too.
Object.setPrototypeOf changes an object's prototype link. Real code rarely uses it, because it can make programs slower. Here it is useful, because it shows the rule clearly.
[] instanceof Array[] instanceof Object3 instanceof Numbernew Number(3) instanceof Numbernew Map() instanceof ArrayObject.create(null) instanceof Object
Put each line under the result it really gives.
Symbol.hasInstance: a class sets its own instanceof rule
STEP THROUGHA class can decide for itself how instanceof answers. It adds a static method named Symbol.hasInstance. When you write value instanceof Duck, JavaScript calls Duck[Symbol.hasInstance](value). Whatever that method returns, true or false, becomes the answer.
This is powerful, and it can surprise people. Use it rarely, and write the rule down in a comment.
See how Symbol.hasInstance turns instanceof into a check of what an object can do.
script
static [Symbol.hasInstance](value) { return typeof value?.quack === "function"; }} const toy = { quack() { return "squeak"; } };const rock = { sound: "thud" };console.log(toy instanceof Duck);console.log(rock instanceof Duck);Duck sets its own rule for instanceof: "Does it have a quack() function?" The toy does, so it passes. The rock does not.This Duck example uses an idea called duck typing: "If it quacks like a duck, treat it like a duck." It checks what an object can do, not how it was made. Any object with a quack function passes, even though Duck never made it.
That is handy for small checks. But a reader may expect instanceof to mean "made by this class", so it can confuse them.
Where you’ll use this
Most programs need only a few built-in subclasses. Here is a good test. After you add your methods, is it still true to say "this is a kind of array" or "this is a kind of map"? If yes, a subclass fits. Some good examples:
- A fixed list of words, with helper methods.
- A
DefaultMapfor counting things. - A list for your app that is easy to read when you debug it.
class CounterMap extends Map {
add(key, amount = 1) {
const next = (this.get(key) ?? 0) + amount;
this.set(key, next);
return next;
}
}
const views = new CounterMap();
views.add("/home");
views.add("/home", 2);
console.log(views.get("/home")); // 3Line 3 reads the old count, or 0 if there is none, and adds amount. Line 4 saves the new count. Lines 10 and 11 add 1 and then 2, so line 12 prints 3.
This example doesn't need Symbol.species. Map's set method changes the same map and gives back that same map. It never makes a new one. Symbol.species matters most for methods that make new lists.
You can also extend Error, the built-in for errors. A subclass can give the error a clear name. It can also keep the original problem as its cause.
class CheckoutError extends Error {
constructor(message, options) {
super(message, options);
this.name = "CheckoutError";
}
}
const cause = new Error("payment declined");
const error = new CheckoutError("Could not place order", { cause });
console.log(error.name);
console.log(error.message);
console.log(error.cause.message);Line 3, super(message, options), lets Error save the message and the cause. Line 4 sets name, the label that logs show. Line 8 makes the original error. Line 9 wraps it in a CheckoutError. Lines 10 to 12 print:
CheckoutErrorCould not place orderpayment declined
Keeping the cause helps later. The error tells the user what failed, and the cause tells you why it failed.
A static member belongs to the class itself, not to the objects it makes. So a subclass has two separate chains:
- The class's own chain:
NamedMaplinks toMap. This is how static methods are shared. - The objects' chain:
NamedMap.prototypelinks toMap.prototype. This is how normal methods are shared.
So NamedMap instanceof Map is false, because Map.prototype is not on the class's own chain. But NamedMap.prototype instanceof Map is true.
class NamedMap extends Map {}
console.log(NamedMap instanceof Map);
console.log(NamedMap.prototype instanceof Map);Sometimes you only need a list that you control. Then composition can be simpler. Composition means your object holds an ordinary array, instead of being one. Other code can only use the methods you write, not every Array method.
class TodoBag {
constructor(items = []) {
this.items = [...items];
}
add(item) {
this.items.push(item);
}
count() {
return this.items.length;
}
}
const todos = new TodoBag(["plan"]);
todos.add("ship");
console.log(todos.count());
console.log(todos instanceof Array);Line 3 copies the starting items into this.items. Line 13 makes the list with "plan", and line 14 adds "ship". Line 15 prints 2. Line 16 prints false, because todos is not an array.
Choose composition when your object is not really an array. Also choose it when fewer methods make the object easier for a team to use.
Pitfalls & trade-offs
A subclass is safer than changing a built-in prototype. But it still has a few traps. This section covers three:
- Arrays that come from another realm, such as an iframe.
- Built-ins that are awkward to extend.
- The
Arrayconstructor when it gets a single number.
A realm is a separate JavaScript world with its own built-ins. Each iframe on a page is its own realm. (An iframe is a page shown inside another page.) Each realm has its own Array. So an array made in an iframe can give false for instanceof Array in the main page.
To check for an array, use Array.isArray. It works for arrays from any realm.
const vm = require("node:vm");
const otherRealmArray = vm.runInNewContext("[1, 2, 3]");
console.log(otherRealmArray instanceof Array);
console.log(Array.isArray(otherRealmArray));Line 2 uses Node's vm module to make an array in a new realm. Line 3 prints false, and line 4 prints true. In a browser, the other realm would be an iframe instead.
The table below lists some built-ins. Each row says how it feels to extend that built-in, and why.
| Built-in | How does it feel to extend? | Why? |
|---|---|---|
| Array | Useful, with a few surprises. | It has length, empty places, and species. Its constructor needs care. |
| Map | Usually smooth. | super() sets up the map storage. Changing how get reads is a common, simple change. |
| Set | Usually smooth. | Like Map, it does one clear job and has few constructor traps. |
| Error | Common, with its own lesson. | Custom errors get a full lesson later: Throwing & custom errors. |
| Promise | Awkward. | Chaining, species, and waiting for results make Promise subclasses hard to follow. |
| Date | Awkward. | Date keeps its time in hidden internal storage. An object that holds a Date is often clearer. |
- An Array for a small list with helper methods
- A Map that gives a default value for missing keys
- A Set with helper methods for your app
- A Promise with many custom chain methods
- A Date subclass for every business calendar rule
- A RegExp subclass that changes how matching works
Put each idea under the group it fits: usually works well, or often awkward.
new Array(3) does not make [3]. It makes an array whose length is 3, with three empty places. If your subclass passes one number on to super, it gets the same behavior.
class BadList extends Array {
constructor(items) {
super(items);
}
}
class GoodList extends Array {
constructor(...items) {
super();
this.push(...items);
}
}
console.log(new BadList(3).length);
console.log(new BadList(["a", "b"]).length);
console.log(new GoodList(3).length);Line 14 prints 3, because a single number means length. Line 15 prints 1, because an array argument is just one item. Line 16 also prints 1, but this time the list really holds the number 3 at index 0.
When you mean "these are the items", use rest parameters. Writing ...items collects all the arguments into one array. Then start empty and push the items in: constructor(...items) { super(); this.push(...items); }.
If you really mean "make room for this many items", say so in a comment. Then test what happens with the empty places.
Common misconceptions
- “Extending Array changes all arrays.” No. Changing
Array.prototypechanges all arrays. A subclass is a separate class, so only its own lists get the new methods. - “Array methods always give back plain arrays.” Not on a subclass. By default,
mapandfiltergive back your subclass, unlessSymbol.speciessays otherwise. - “instanceof checks the class name.” No. It looks for one exact object,
Thing.prototype, on the prototype chain. Names don't matter. - “instanceof always tells you the right type.” Not always. Values from another realm, changed prototypes, primitives, and
Symbol.hasInstancecan all surprise you. To check for an array, useArray.isArray. - “Symbol.hasInstance is just decoration.” No. It fully decides what
instanceofanswers for that class.
Practice exercises
5 EXERCISESsum() to an Array subclassWrite a subclass of Array with a sum method. It should give back the total of all the numbers in the list.
class NumberList extends Array {
sum() {
return this.reduce((total, value) => total + value, 0);
}
}
const scores = new NumberList(2, 3, 5);
console.log(scores.sum());class NumberList extends Array {
sum() {
return this.reduce((total, value) => total + value, 0);
}
}
const scores = new NumberList(2, 3, 5);
console.log(scores.sum());NumberList gets array storage and reduce from Array. sum adds each item to a running total, so 2 + 3 + 5 gives 10.
map gives backRead the code. Predict the two lines it prints, then run it.
class Tags extends Array {}
const tags = new Tags("js", "css");
const upper = tags.map(tag => tag.toUpperCase());
console.log(upper instanceof Tags);
console.log(upper.constructor.name);class Tags extends Array {}
const tags = new Tags("js", "css");
const upper = tags.map(tag => tag.toUpperCase());
console.log(upper instanceof Tags);
console.log(upper.constructor.name);Tags has no species getter, so map makes another Tags. The two lines are true and Tags.
map give back plain arraysThis class has a species getter that returns Array. Predict the two true or false values it prints.
class Tags extends Array {
static get [Symbol.species]() { return Array; }
}
const tags = new Tags("js", "css");
const upper = tags.map(tag => tag.toUpperCase());
console.log(upper instanceof Tags);
console.log(upper instanceof Array);class Tags extends Array {
static get [Symbol.species]() { return Array; }
}
const tags = new Tags("js", "css");
const upper = tags.map(tag => tag.toUpperCase());
console.log(upper instanceof Tags);
console.log(upper instanceof Array);The species getter tells map to make a plain array. So upper is not a Tags (false), but it is still an Array (true).
instanceof ruleWrite a class whose instanceof check accepts any object that has a quack function.
class Quacker {
static [Symbol.hasInstance](value) {
return typeof value?.quack === "function";
}
}
console.log({ quack() {} } instanceof Quacker);
console.log({ quack: true } instanceof Quacker);class Quacker {
static [Symbol.hasInstance](value) {
return typeof value?.quack === "function";
}
}
console.log({ quack() {} } instanceof Quacker);
console.log({ quack: true } instanceof Quacker);The rule accepts the object whose quack is a function. It rejects the object whose quack is just true.
Fix the constructor so new GoodList(3) holds the number 3.
class GoodList extends Array {
constructor(...items) {
super();
this.push(...items);
}
}
console.log(new GoodList(3).length);
console.log(new GoodList(3)[0]);class GoodList extends Array {
constructor(...items) {
super();
this.push(...items);
}
}
console.log(new GoodList(3).length);
console.log(new GoodList(3)[0]);constructor(...items) starts with an empty list. Then push adds the real items. new GoodList(3) holds the number 3, so its length is 1 and index 0 is 3.
Check your understanding
7 QUESTIONSQuestion 1 of 7Why make a subclass of Array instead of adding
Array.prototype.sum?Choose an answer to see the explanation.
Question 2 of 7What does the Bag
mapexample print?Read the code, then predictclass Bag extends Array {} const bag = new Bag("a", "b"); const copy = bag.map(x => x); console.log(copy instanceof Bag);Choose an answer to see the explanation.
Question 3 of 7What does the Bag species getter example print?
Read the code, then predictclass Bag extends Array { static get [Symbol.species]() { return Array; } } const bag = new Bag("a", "b"); console.log(bag.filter(Boolean) instanceof Bag);Choose an answer to see the explanation.
Question 4 of 7How does a normal
value instanceof Constructorcheck decide?Choose an answer to see the explanation.
Question 5 of 7What does the base-class instanceof example print?
Read the code, then predictclass Base {} class Child extends Base {} const value = new Child(); console.log(value instanceof Base);Choose an answer to see the explanation.
Question 6 of 7What is
Symbol.hasInstancefor?Choose an answer to see the explanation.
Question 7 of 7What does the Duck hasInstance example print?
Read the code, then predictclass Duck { static [Symbol.hasInstance](x) { return typeof x?.quack === "function"; } } console.log({ quack() {} } instanceof Duck);Choose an answer to see the explanation.
Key takeaways
- Extend a built-in when your object really is a kind of array or map. Don't change the built-in prototypes.
- On a subclass of
Array,mapandfiltergive back the subclass by default. Symbol.specieslets a subclass choose what kind of list those methods make.instanceofwalks the prototype chain and looks forThing.prototype.Symbol.hasInstancelets a class replace that walk with its own true-or-false rule.
One-line definition: Extending a built-in makes your own version of a standard JavaScript object, like an array or a map. instanceof checks whether a value was made from a class by walking the prototype chain, unless the class sets its own rule with Symbol.hasInstance.
Up next: Mixins & composition.