The future of JavaScript: classes





Good day, friends!



Today I want to talk to you about three proposals related to JavaScript classes that are in 3 stages of consideration:





Considering that these proposals fully comply with the logic of further development of classes and use the existing syntax, you can be sure that they will be standardized without any major changes. This is also evidenced by the implementation of the named "features" in modern browsers.



Let's remember what classes are in JavaScript.



For the most part, classes are so-called "syntactic sugar" (abstraction, or, more simply, a wrapper) for constructor functions. Such functions are used to implement the Constructor design pattern. This pattern, in turn, is implemented (in JavaScript) using the prototypal inheritance model. The prototypal inheritance model is sometimes defined as a stand-alone "Prototype" pattern. You can read more about design patterns here .



What is a prototype? It is an object that acts as a blueprint or blueprint for other objects - instances. A constructor is a function that allows you to create instance objects based on a prototype (class, superclass, abstract class, etc.). The process of passing properties and functions from prototype to instance is called inheritance. Properties and functions in class terminology are usually called fields and methods, but, de facto, they are one and the same.



What does a constructor function look like?



//      
'use strict'
function Counter(initialValue = 0) {
  this.count = initialValue
  //   ,   this
  console.log(this)
}

      
      





We define a "Counter" function that takes an "initialValue" parameter with a default value of 0. This parameter is assigned to the "count" instance property when the instance is initialized. The "this" context in this case is the object created (returned) by the function. In order to tell JavaScript to call not just a function, but a constructor function, you must use the "new" keyword:



const counter = new Counter() // { count: 0, __proto__: Object }

      
      





As we can see, the constructor function returns an object with a property we defined "count" and a prototype (__proto__) as a global object "Object", to which prototype chains of almost all types (data) in JavaScript go back (except for objects without a prototype created by using Object.create (null)). This is why they say that in JavaScript "everything is an object".



Calling a constructor function without "new" will throw a "TypeError" (type error) indicating that "the property 'count' cannot be assigned undefined":



const counter = Counter() // TypeError: Cannot set property 'count' of undefined

//   
const counter = Counter() // Window

      
      





This is because the "this" value inside a function is "undefined" in strict mode, and the global "Window" object in non-strict mode.



Let's add distributed (shared, common to all instances) methods to the constructor function to increase, decrease, reset and get the counter value:



Counter.prototype.increment = function () {
  this.count += 1
  //  this,        
  return this
}

Counter.prototype.decrement = function () {
  this.count -= 1
  return this
}

Counter.prototype.reset = function () {
  this.count = 0
  return this
}

Counter.prototype.getInfo = function () {
  console.log(this.count)
  return this
}

      
      





If you define methods in the constructor function itself, and not in its prototype, then for each instance its own methods will be created, which can make it difficult to subsequently change the functionality of the instances. Previously, this could also lead to performance issues.



Adding multiple methods to the prototype of a constructor function can be optimized as follows:



;(function () {
  this.increment = function () {
    this.count += 1
    return this
  }

  this.decrement = function () {
    this.count -= 1
    return this
  }

  this.reset = function () {
    this.count = 0
    return this
  }

  this.getInfo = function () {
    console.log(this.count)
    return this
  }
//     -
// https://developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Global_Objects/Function/call
}.call(Counter.prototype))

      
      





Or you can make it even easier:



//   ,     
Object.assign(Counter.prototype, {
  increment() {
    this.count += 1
    return this
  },

  decrement() {
    this.count -= 1
    return this
  },

  reset() {
    this.count = 0
    return this
  },

  getInfo() {
    console.log(this.count)
    return this
  }
})

      
      





Let's use our methods:



counter
  .increment()
  .increment()
  .getInfo() // 2
  .decrement()
  .getInfo() // 1
  .reset()
  .getInfo() // 0

      
      





The class syntax is more concise:



class _Counter {
  constructor(initialValue = 0) {
    this.count = initialValue
  }

  increment() {
    this.count += 1
    return this
  }

  decrement() {
    this.count -= 1
    return this
  }

  reset() {
    this.count = 0
    return this
  }

  getInfo() {
    console.log(this.count)
    return this
  }
}

const _counter = new _Counter()
_counter
  .increment()
  .increment()
  .getInfo() // 2
  .decrement()
  .getInfo() // 1
  .reset()
  .getInfo() // 0

      
      





Let's look at a more complex example to demonstrate how JavaScript inheritance works. Let's create a class "Person" and its subclass "SubPerson".



The Person class defines the properties firstName, lastName, and age, as well as the getFullName (get first and last names), getAge (get age) and saySomething ”(saying a phrase).



The SubPerson subclass inherits all the properties and methods of Person, and also defines new fields for lifestyle, skill, and interest, as well as new getInfo methods for getting full name by calling the parent-inherited method "getFullName" and lifestyle), "getSkill" (getting a skill), "getLike" (getting a hobby) and "setLike" (defining a hobby).



Constructor function:



const log = console.log

function Person({ firstName, lastName, age }) {
  this.firstName = firstName
  this.lastName = lastName
  this.age = age
}

;(function () {
  this.getFullName = function () {
    log(`   ${this.firstName} ${this.lastName}`)
    return this
  }
  this.getAge = function () {
    log(`  ${this.age} `)
    return this
  }
  this.saySomething = function (phrase) {
    log(`  : "${phrase}"`)
    return this
  }
}.call(Person.prototype))

const person = new Person({
  firstName: '',
  lastName: '',
  age: 30
})

person.getFullName().getAge().saySomething('!')
/*
      
    30 
    : "!"
*/

function SubPerson({ lifestyle, skill, ...rest }) {
  //   Person   SubPerson    
  Person.call(this, rest)
  this.lifestyle = lifestyle
  this.skill = skill
  this.interest = null
}

//   Person  SubPerson
SubPerson.prototype = Object.create(Person.prototype)
//      
Object.assign(SubPerson.prototype, {
  getInfo() {
    this.getFullName()
    log(` ${this.lifestyle}`)
    return this
  },

  getSkill() {
    log(` ${this.lifestyle}  ${this.skill}`)
    return this
  },

  getLike() {
    log(
      ` ${this.lifestyle} ${
        this.interest ? ` ${this.interest}` : '  '
      }`
    )
    return this
  },

  setLike(value) {
    this.interest = value
    return this
  }
})

const developer = new SubPerson({
  firstName: '',
  lastName: '',
  age: 25,
  lifestyle: '',
  skill: '   JavaScript'
})

developer
  .getInfo()
  .getAge()
  .saySomething(' -  !')
  .getSkill()
  .getLike()
/*
      
   
    25 
    : " -  !"
        JavaScript
      
*/

developer.setLike(' ').getLike()
//     

      
      





Class:



const log = console.log

class _Person {
  constructor({ firstName, lastName, age }) {
    this.firstName = firstName
    this.lastName = lastName
    this.age = age
  }

  getFullName() {
    log(`   ${this.firstName} ${this.lastName}`)
    return this
  }

  getAge() {
    log(`  ${this.age} `)
    return this
  }

  saySomething(phrase) {
    log(`  : "${phrase}"`)
    return this
  }
}

const _person = new Person({
  firstName: '',
  lastName: '',
  age: 30
})

_person.getFullName().getAge().saySomething('!')
/*
      
    30 
    : "!"
*/

class _SubPerson extends _Person {
  constructor({ lifestyle, skill /*, ...rest*/ }) {
    //  super()    Person.call(this, rest)
    // super(rest)
    super()
    this.lifestyle = lifestyle
    this.skill = skill
    this.interest = null
  }

  getInfo() {
    // super.getFullName()
    this.getFullName()
    log(` ${this.lifestyle}`)
    return this
  }

  getSkill() {
    log(` ${this.lifestyle}  ${this.skill}`)
    return this
  }

  get like() {
    log(
      ` ${this.lifestyle} ${
        this.interest ? ` ${this.interest}` : '  '
      }`
    )
  }

  set like(value) {
    this.interest = value
  }
}

const _developer = new SubPerson({
  firstName: '',
  lastName: '',
  age: 25,
  lifestyle: '',
  skill: '   JavaScript'
})

_developer
  .getInfo()
  .getAge()
  .saySomething(' -  !')
  .getSkill().like
/*
      
   
    25 
    : " -  !"
        JavaScript
      
*/

developer.like = ' '
developer.like
//     

      
      





I think everything is clear here. Moving on.



The main problem of inheritance in JavaScript was and still is the lack of built-in multiple inheritance, i.e. the ability of a subclass to inherit properties and methods of several classes at the same time. Of course, since anything is possible in JavaScript, we can simulate multiple inheritance, for example, using this mixin:



// https://www.typescriptlang.org/docs/handbook/mixins.html
function applyMixins(derivedCtor, constructors) {
  constructors.forEach((baseCtor) => {
    Object.getOwnPropertyNames(baseCtor.prototype).forEach((name) => {
      Object.defineProperty(
        derivedCtor.prototype,
        name,
        Object.getOwnPropertyDescriptor(baseCtor.prototype, name) ||
          Object.create(null)
      )
    })
  })
}

class A {
  sayHi() {
    console.log(`${this.name} : "!"`)
  }
  sameName() {
    console.log('  ')
  }
}

class B {
  sayBye() {
    console.log(`${this.name} : "!"`)
  }
  sameName() {
    console.log('  B')
  }
}

class C {
  name = ''
}

applyMixins(C, [A, B])

const c = new C()

//  ,    A
c.sayHi() //  : "!"

//  ,    B
c.sayBye() //  : "!"

//     
c.sameName() //   B

      
      





However, this is not a complete solution and is just a hack to squeeze JavaScript into the framework of object-oriented programming.



Let's go directly to the innovations offered by the proposals indicated at the beginning of the article.



Today, given the standardized features, the class syntax looks like this:



const log = console.log

class C {
  constructor() {
    this.publicInstanceField = '  '
    this.#privateInstanceField = '  '
  }

  publicInstanceMethod() {
    log('  ')
  }

  //     
  getPrivateInstanceField() {
    log(this.#privateInstanceField)
  }

  static publicClassMethod() {
    log('  ')
  }
}

const c = new C()

console.log(c.publicInstanceField) //   

//         
// console.log(c.#privateInstanceField) // SyntaxError: Private field '#privateInstanceField' must be declared in an enclosing class

c.getPrivateInstanceField() //   

c.publicInstanceMethod() //   

C.publicClassMethod() //   

      
      





It turns out that we can define public and private fields and public methods of instances, as well as public methods of a class, but we cannot define private methods of instances, as well as public and private fields of a class. Well, in fact, it is still possible to define a public field of a class:



C.publicClassField = '  '
console.log(C.publicClassField) //   

      
      





But, you must admit that it does not look very good. It seems that we are back to working with prototypes.



The first proposal allows you to define public and private instance fields without using a constructor:



publicInstanceField = '  '
#privateInstanceField = '  '

      
      





The second proposal allows you to define private instance methods:



#privateInstanceMethod() {
  log('  ')
}

//    
getPrivateInstanceMethod() {
  this.#privateInstanceMethod()
}

      
      





And finally, the third proposal allows you to define public and private (static) fields, as well as private (static) methods of a class:



static publicClassField = '  '
static #privateClassField = '  '

static #privateClassMethod() {
  log('  ')
}

//     
static getPrivateClassField() {
  log(C.#privateClassField)
}

//    
static getPrivateClassMethod() {
  C.#privateClassMethod()
}

      
      





This is how the complete set will look (in fact, it already looks):



const log = console.log

class C {
  // class field declarations
  // https://github.com/tc39/proposal-class-fields
  publicInstanceField = '  '

  #privateInstanceField = '  '

  publicInstanceMethod() {
    log('  ')
  }

  // private methods and getter/setters
  // https://github.com/tc39/proposal-private-methods
  #privateInstanceMethod() {
    log('  ')
  }

  //     
  getPrivateInstanceField() {
    log(this.#privateInstanceField)
  }

  //    
  getPrivateInstanceMethod() {
    this.#privateInstanceMethod()
  }

  // static class features
  // https://github.com/tc39/proposal-static-class-features
  static publicClassField = '  '
  static #privateClassField = '  '

  static publicClassMethod() {
    log('  ')
  }

  static #privateClassMethod() {
    log('  ')
  }

  //     
  static getPrivateClassField() {
    log(C.#privateClassField)
  }

  //    
  static getPrivateClassMethod() {
    C.#privateClassMethod()
  }

  //         
  getPublicAndPrivateClassFieldsFromInstance() {
    log(C.publicClassField)
    log(C.#privateClassField)
  }

  //         
  static getPublicAndPrivateInstanceFieldsFromClass() {
    log(this.publicInstanceField)
    log(this.#privateInstanceField)
  }
}

const c = new C()

console.log(c.publicInstanceField) //   

//           
// console.log(c.#privateInstanceField) // SyntaxError: Private field '#privateInstanceField' must be declared in an enclosing class

c.getPrivateInstanceField() //   

c.publicInstanceMethod() //   

//          
// c.#privateInstanceMethod() // Error

c.getPrivateInstanceMethod() //   

console.log(C.publicClassField) //   

// console.log(C.#privateClassField) // Error

C.getPrivateClassField() //   

C.publicClassMethod() //   

// C.#privateClassMethod() // Error

C.getPrivateClassMethod() //   

c.getPublicAndPrivateClassFieldsFromInstance()
//   
//   

//        ,
//         
// C.getPublicAndPrivateInstanceFieldsFromClass()
// undefined
// TypeError: Cannot read private member #privateInstanceField from an object whose class did not declare it

      
      





Everything would be fine, only there is one interesting nuance: private fields are not inherited. In TypeScript and other programming languages, there is a special property, usually called “protected,” which cannot be accessed directly, but which can be inherited along with public properties.



It's worth noting that the words "private", "public" and "protected" are reserved words in JavaScript. If you try to use them in strict mode, an exception is thrown:



const private = '' // SyntaxError: Unexpected strict mode reserved word
const public = '' // Error
const protected = '' // Error

      
      





Therefore, the hope for the implementation of protected class fields in the distant future remains.



I draw your attention to the fact that the technique of encapsulating variables, i.e. their protection from outside access is as old as JavaScript itself. Prior to the standardization of private class fields, closures were commonly used to hide variables, as well as the Factory and Module design patterns. Let's look at these patterns using the example of a shopping cart.



Module:



const products = [
  {
    id: '1',
    title: '',
    price: 50
  },
  {
    id: '2',
    title: '',
    price: 150
  },
  {
    id: '3',
    title: '',
    price: 100
  }
]

const cartModule = (() => {
  let cart = []

  function getProductCount() {
    return cart.length
  }

  function getTotalPrice() {
    return cart.reduce((total, { price }) => (total += price), 0)
  }

  return {
    addProducts(products) {
      products.forEach((product) => {
        cart.push(product)
      })
    },
    removeProduct(obj) {
      for (const key in obj) {
        cart = cart.filter((prod) => prod[key] !== obj[key])
      }
    },
    getInfo() {
      console.log(
        `  ${getProductCount()} ()  ${
          getProductCount() > 1 ? ' ' : ''
        } ${getTotalPrice()} `
      )
    }
  }
})()

//       
console.log(cartModule) // { addProducts: ƒ, removeProduct: ƒ, getInfo: ƒ }

//    
cartModule.addProducts(products)
cartModule.getInfo()
//   3 ()    300 

//     2
cartModule.removeProduct({ id: '2' })
cartModule.getInfo()
//   2 ()    150 

//        
console.log(cartModule.cart) // undefined
// cartModule.getProductCount() // TypeError: cartModule.getProductCount is not a function

      
      





Factory:



function cartFactory() {
  let cart = []

  function getProductCount() {
    return cart.length
  }

  function getTotalPrice() {
    return cart.reduce((total, { price }) => (total += price), 0)
  }

  return {
    addProducts(products) {
      products.forEach((product) => {
        cart.push(product)
      })
    },
    removeProduct(obj) {
      for (const key in obj) {
        cart = cart.filter((prod) => prod[key] !== obj[key])
      }
    },
    getInfo() {
      console.log(
        `  ${getProductCount()} ()  ${
          getProductCount() > 1 ? ' ' : ''
        } ${getTotalPrice()} `
      )
    }
  }
}

const cart = cartFactory()

cart.addProducts(products)
cart.getInfo()
//   3 ()    300 

cart.removeProduct({ title: '' })
cart.getInfo()
//   2 ()   200 

console.log(cart.cart) // undefined
// cart.getProductCount() // TypeError: cart.getProductCount is not a function

      
      





Class:



class Cart {
  #cart = []

  #getProductCount() {
    return this.#cart.length
  }

  #getTotalPrice() {
    return this.#cart.reduce((total, { price }) => (total += price), 0)
  }

  addProducts(products) {
    this.#cart.push(...products)
  }

  removeProduct(obj) {
    for (const key in obj) {
      this.#cart = this.#cart.filter((prod) => prod[key] !== obj[key])
    }
  }

  getInfo() {
    console.log(
      `  ${this.#getProductCount()} ()  ${
        this.#getProductCount() > 1 ? ' ' : ''
      } ${this.#getTotalPrice()} `
    )
  }
}

const _cart = new Cart()

_cart.addProducts(products)
_cart.getInfo()
//   3 ()    300 

_cart.removeProduct({ id: '1', price: 100 })
_cart.getInfo()
//   1 ()    150 

console.log(_cart.cart) // undefined
// console.log(_cart.#cart) // SyntaxError: Private field '#cart' must be declared in an enclosing class
// _cart.getTotalPrice() // TypeError: cart.getTotalPrice is not a function
// _cart.#getTotalPrice() // Error

      
      





As we can see, the patterns "Module" and "Factory" are in no way inferior to the class, except that the syntax of the latter is a little more concise, but they allow you to completely abandon the use of the keyword "this", the main problem of which is the loss of context when used in arrow functions and event handlers. This necessitates binding them to an instance in the constructor.



Finally, let's look at an example of creating a button web component using the class syntax (from the text of one of the sentences with a slight modification).



Our component extends the built-in HTML element of the button, adding the following to its functionality: when the button is left-clicked, the counter value is increased by 1, when the button is right-clicked, the counter value is decreased by 1. At the same time, we can use any number of buttons with own context and state:



// https://developer.mozilla.org/ru/docs/Web/Web_Components
class Counter extends HTMLButtonElement {
  #xValue = 0

  get #x() {
    return this.#xValue
  }

  set #x(value) {
    this.#xValue = value
    //     
    // https://developer.mozilla.org/ru/docs/DOM/window.requestAnimationFrame
    // https://developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Global_Objects/Function/bind
    requestAnimationFrame(this.#render.bind(this))
  }

  #increment() {
    this.#x++
  }

  #decrement(e) {
    //    
    e.preventDefault()
    this.#x--
  }

  constructor() {
    super()
    //     
    this.onclick = this.#increment.bind(this)
    this.oncontextmenu = this.#decrement.bind(this)
  }

  //    React/Vue ,  ,    DOM
  connectedCallback() {
    this.#render()
  }

  #render() {
    //    ,  0 -   
    this.textContent = `${this.#x} - ${
      this.#x < 0 ? '' : ''
    } ${this.#x & 1 ? '' : ''} `
  }
}

//  -
customElements.define('btn-counter', Counter, { extends: 'button' })

      
      





Result:







It seems that, on the one hand, classes will not gain widespread acceptance in the developer community until they solve, let's call it “this problem”. It is no coincidence that after a long time using classes (class components), the React team ditched them in favor of functions (hooks). A similar trend is observed in the Vue Composition API. On the other hand, many of the ECMAScript developers, web components engineers at Google, and the TypeScript team are actively working on the development of JavaScript's "object-oriented" component, so you shouldn't discount classes in the next few years.



All the code in the article is here .



You can read more about object-oriented JavaScript here .



The article turned out to be slightly longer than I planned, but I hope you were interested. Thank you for your attention and have a nice day.



All Articles