JS Bits with Bill
JS Bits with Bill

JS Bits with Bill

Symbols Are Your Friend Part IV: Symbol.search, Symbol.split, & Symbol.species

Symbols Are Your Friend Part IV: Symbol.search, Symbol.split, & Symbol.species

JS Bits with Bill's photo
JS Bits with Bill

Published on Oct 27, 2020

5 min read

Symbols Are Your Friend Series


Since the wildly popular Symbols Are Your Friend article series has the momentum of a runaway freight train 🚂 (not really), let's check out some more static Symbol properties!

  • Symbol.search
  • Symbol.split
  • Symbol.species

Symbol.search This symbol defines the method that returns the index of a regular expression within a string. It is called internally when String.prototype.search() is used:

Default behavior:

'Wayne Gretzky: The Great One'.search(/Great/); // Returns 19

As you can see, String.search() returns the index of the provided regular expression. We can modify this behavior with Symbol.search:

const testString = 'Poke Tuna Meal: $10';
const priceRegEx = /\$/;

priceRegEx[Symbol.search] = function(str) {
  const indexResult = (str.match(this) || []).index;
  return `Position: ${indexResult || 'not found'}`;
};

testString.search(priceRegEx); // Returns "Position: 16"
'Water: FREE'.search(priceRegEx); // Returns "Position: not found"

Note that if you provide a string to String.search() it will be implicitly converted to a Regular Expression thus allowing the use of Symbol.search. The same applies to the next few static Symbol properties.


Symbol.split Defines the method that splits a string at the indices that match a regular expression.

Default behavior:

'One Two Three'.split(' '); // Returns ["One", "Two", "Three"]

Symbol.split modification:

const splitRegEx = / /;

splitRegEx[Symbol.split] = function(string) {

  // Create copy of regex to prevent infinite loop
  const regExCopy = new RegExp(this);

  // Create modified result array
  const array = string.split(regExCopy);
  return array.map((item, index) => {
      return `Char ${index + 1}: ${item}`;
  });

};

'Yamcha Goku Vegeta'.split(splitRegEx);
/*
  Returns:
  ["Char 1: Yamcha", "Char 2: Goku", "Char 3: Vegeta"]
*/

Symbol.species This one's a bit tricky to wrap your head around. According to MDN, Symbol.species specifies a function-valued property that the constructor function uses to create derived objects.

Essentially what this is saying is that Symbol.species lets you change the default constructor of objects returned via methods on a "derived" class (a subclassed object).

For example, let's say we have a basic Fighter class and an AdvancedFighter class that extends Fighter. Objects created via the AdvancedFighter class will automatically inherit the Fighter's prototype by way of the constructor. Additionally, subclasses of AdvancedFighter will be instances of both AdvancedFighter and Fighter:

class Fighter {
  constructor(name, weapon) {
    this.name = name;
    this.weapon = weapon;
  }

  basicAttack() {
    console.log(`${this.name}: Uses ${this.weapon} - 2 dmg`);
  }
}

class AdvancedFighter extends Fighter {

  advancedAttack() {
    console.log(`${this.name}: Uses ${this.weapon} - 10 dmg`);
  }

  // Create a subclass object with the species we specified above
  createSensei() {
    return new this.constructor(this.name, this.weapon);
  }
}

class Sensei {
  constructor(name, weapon) {
    this.name = name;
    this.weapon = weapon;
  }

  generateWisdom() {
    console.log('Lost time is never found again.');
  }
}


const splinter = new AdvancedFighter('Splinter', 'fists');
const splinterSensei = splinter.createSensei();

console.log(splinterSensei instanceof Fighter);  // true
console.log(splinterSensei instanceof AdvancedFighter); // true
console.log(splinterSensei instanceof Sensei); // false


console.log(splinterSensei.basicAttack()); // ✅ Logs attack
console.log(splinterSensei.generateWisdom()); // ❌ TypeError

You can see in this code, we also created a Sensei class. We can use Symbol.species to specify AdvancedFighter's derived classes to use the Sensei constructor:

class Fighter {
  constructor(name, weapon) {
    this.name = name;
    this.weapon = weapon;
  }

  basicAttack() {
    console.log(`${this.name}: Uses ${this.weapon} - 2 dmg`);
  }
}

class AdvancedFighter extends Fighter {

  // Override default constructor for subclasses
  static get [Symbol.species]() { return Sensei; }

  advancedAttack() {
    console.log(`${this.name}: Uses ${this.weapon} - 10 dmg`);
  }

  // Create a subclass object with the species we specified above
  createSensei() {
    return new (this.constructor[Symbol.species] ||
      this.constructor)(this.name, this.weapon);
  }
}

class Sensei {
  constructor(name, weapon) {
    this.name = name;
    this.weapon = weapon;
  }

  generateWisdom() {
    console.log('Lost time is never found again.');
  }
}


const splinter = new AdvancedFighter('Splinter', 'fists');
const splinterSensei = splinter.createSensei();

console.log(splinterSensei instanceof Fighter);  // false
console.log(splinterSensei instanceof AdvancedFighter); // false
console.log(splinterSensei instanceof Sensei); // true

console.log(splinterSensei.generateWisdom()); // ✅ Logs wisdom
console.log(splinterSensei.basicAttack()); // ❌ TypeError

The confusing part here is that Symbol.species only specifies the constructor of subclass objects. These are created when a class method creates a new instance of a class with...

return new this.constructor();

if there is no defined species or:

return this.constructor[Symbol.species]();

if we've added a custom species getter.

We can combine some Symbol static property concepts together to illustrate this further:

class MyRegExp extends RegExp {
  [Symbol.search](str) {
    // Hack search() to return "this" (an instance of MyRegExp)
    return new (this.constructor[Symbol.species] ||
      this.constructor)();
  }
}

const fooRegEx = new MyRegExp('foo');
const derivedObj = 'football'.search(fooRegEx);

console.log(derivedObj instanceof MyRegExp); // true
console.log(derivedObj instanceof RegExp); // true
class MyRegExp extends RegExp {

  // Force MyRegExp subclasses to use the SpecialClass constructor
  static get [Symbol.species]() { return SpecialClass; }

  [Symbol.search](str) {
    // Hack search() to return "this" (an instance of MyRegExp)
    return new (this.constructor[Symbol.species] ||
      this.constructor)();
  }
}

class SpecialClass {
  message() {
    console.log('I\'m special!');
  }
}

const fooRegEx = new MyRegExp('foo');
const derivedObj = 'football'.search(fooRegEx);

console.log(derivedObj instanceof MyRegExp); // false
console.log(derivedObj instanceof RegExp); // false
console.log(derivedObj instanceof SpecialClass); // true

derivedObj.message(); // Logs "I'm special!"

A potential use case for Symbol.species would be if you wanted to create a custom API class object that includes all your internal / private methods but you wish for publicly created subclasses to use a different constructor.

See you in the next part! 👋


Check out more #JSBits at my blog, jsbits-yo.com. Or follow me on Twitter.

 
Share this