Posted 4 months ago. Visible to the public.

The JavaScript Object Model: A deep dive into prototypes and properties

Speaker today is Henning Koch, Head of Development at makandra.

This talk will be in German with English slides.

Introduction

As web developers we work with JavaScript every day, even when our backend code uses another language. While we've become quite adept with JavaScript at a basic level, I think many of us lack a deep understanding of the JavaScript object model and its capabilities.

Some of the questions we will answer in this talk:

  • How does the new keyword construct an object?
  • What is the difference between { prototype }, { __proto__ } and Object.getPrototypeOf()?
  • With ES6 classes, how do I navigate from an instance to its class? How do I navigate from a class to its superclass?
  • How do ES6 classes translate to prototypes and vice versa?

We will spend the first 10 minutes to cover some JavaScript basics and then dive deep into details.

Level 1: Objects

In JavaScript we can create an object without a class:

Copy
let shape = { width: 30, height: 20, computeArea: function() { return this.width * this.height } } shape.width // => 30 shape.height // => 20 shape.height = 100

Note how "methods" are just properties with a function value:

Copy
shape.computeArea // => function() { return this.width * this.height } shape.computeArea() // => 3000

Objects are like hashes

In many ways a JavaScript object is more like a Ruby Hash or a Java Map than an actual Object:

  • Objects can be created without a class
  • Accessing an undefined property will return undefined instead of crashing with NoMethodError (or similar)
  • You may add additional properties after construction
  • You may remove properties after construction

You may also access keys with a "hash index":

Copy
shape.width // => 30 shape['width'] // => 30 shape['width'] = 5 shape.width // => 5

Property keys are strings

Property keys are always strings. Non-string keys are silently cast to strings:

Copy
let hash = {} hash[1000] = 'foo' hash['1000'] = 'bar' hash[document.body] = 'baz' hash // => { '1000': 'bar', '[object HTMLBodyElement]': 'baz' }

If you need a hash of non-string keys, use Map instead:

Copy
let map = new Map() map.set(1000, 'foo') map.set('1000', 'bar') map.set(document.body, 'baz') map.get(1000) // => 'foo' map.get('1000') // => 'bar' map.get(document.body) // => 'baz'

Level 2: Functions

This is a basic function in JavaScript:

Copy
function foo() { return "this is foo" } foo() // => "this is foo"

Functions are also objects that can have properties:

Copy
function foo() { return "this is foo" } foo.key = "value" foo() // => "this is foo" foo.key // => "value"

This might be confusing because typeof distinguishes between functions and objects:

Copy
let fn = function() {} let obj = {} typeof fn // => "function" typeof obj // => "object"

Due to practical considerations, utility libraries like Lodash or Unpoly's up.util module consider functions to be objects:

Copy
up.util.isFunction(fn) // => true up.util.isObject(fn) // => true up.util.isFunction(obj) // => false up.util.isObject(obj) // => true

The value of this

Every function may use this, even if the function does not belong to the object. The default value of this is the global object (window in browsers):

Copy
function sayHello() { console.log("Hello, I am " + this) } sayHello() // logs "Hello, I am [object Window]"

When we assign that same function to an object and call it as a method, that object becomes the this value:

Copy
let object = new String("Alice") object.sayHello = sayHello object.sayHello() // logs "Hello, I am Alice"

Generally speaking: In a method invocation (receiver.method()), the value of this is the receiver ("left side of the dot").

When we pluck the function property from the object and call it without a receiver, we see that this has reverted to its default value:

Copy
let fn = object.sayHello fn() // logs "Hello, I am [object Window]" object.sayHello() // logs "Hello, I am Alice"

As we can see the this value is decided anew for every invocation of that function. There is no hard connection between a function and object.

Function#call() und Function#apply()

Instead of calling function directly with fn() we can also use fn.call(context). With call() we can run the function with any value as the function's this context, even if both never belonged to the same object.

In the example below we will use the array's push() method on an object that isn't an array:

Copy
let object = {} object.push('foo') // => Uncaught TypeError: object.push() is not a function Array.prototype.push.call(object, 'bar') object // => {0: "bar", length: 1} object.push('foo') // => Uncaught TypeError: object.push() is not a function

Instead of call() you may also use apply(). apply() is like call(), but it takes its arguments as an Array instead of varargs:

Copy
let object = {} Array.prototype.push.apply(object, ['bar']) object // => {0: "bar", length: 1}

Level 3: Prototypes

Inheriting behaviors from other objects

When we have many similar objects we want to inherit the shared behavior from a shared place. We don't need classes to use inheritance in JavaScript. Instead we can use Object.create(parent) to inherit from an arbitrary object.

Copy
let parent = { parentKey: 1 } let child = Object.create(parent) child.childKey = 2 child.childKey // => 2 child.parentKey // => 1

This feature is very unique to JavaScript.

Note that when you inspect child in the browser console, you only see { childKey } property. The inherited properties are listed under __proto__ (Chrome) or <prototype> (Firefox).

Constructor functions

Every JavaScript function can be an object constructor for the new keyword:

Copy
function User(firstName, lastName) { this.firstName = firstName this.lastName = lastName } let user = new User('Max', 'Muster') // => { firstName: 'Max', lastName: 'Muster' }

Just in case a function is used as a constructor, every function has a property { prototype }:

Copy
function User(firstName, lastName) { this.firstName = firstName this.lastName = lastName } User.prototype // => { constructor: User } User.prototype.constructor === User // => true

Note how the prototype object contains a { constructor } property pointing back the the function.

When we add properties to the prototype object, these properties are inherited down to constructed instances:

Copy
function User(firstName, lastName) { this.firstName = firstName this.lastName = lastName } User.prototype.fullName = function() { return this.firstName + ' ' + this.lastName } User.prototype // => { constructor: User, fullName: function() { ... } } let user = new User('Max', 'Muster') // => { firstName: 'Max', lastName: 'Muster', __proto__: User.prototype } user.firstName // => "Max" user.lastName // => "Muster" user.fullName() // => "Max Muster"

Note how { firstName } and { lastName } are user's own properties, since they were defined directly on user. Only { fullName } is inherited.

Here is what the new keyword actually does when you type new Constructor():

  1. It creates a new object that inherits from Constructor.prototype
  2. It calls the Constructor() function, using the created object as the this context

That means we can create instances of a "class" without using the new keyword:

Copy
let user = Object.create(User.prototype) // => { __proto__: User.prototype } User.call(user, 'Max', 'Muster') // run the constructor with the new object as `this` user // => { __proto__: User.prototype, firstName: 'Max', lastName: 'Muster' } user.firstName // => "Max" user.lastName // => "Muster" user.lastName() // => "Max Muster"

ES6 classes are a nicer way to define constructors and prototypes

When you have an ES6 transpiler or don't support IE11, we can use class keyword.

ES6 classes bring some syntactic sugar to define constructors and prototypes:

Copy
class User { constructor(firstName, lastName) { this.firstName = firstName this.lastName = lastName } fullName() { return this.firstName + ' ' + this.lastName } }

Even when you don't use a transpiler, the browser's JavaScript VM converts the ES6 version to the prototype version when it parses your script.

JavaScript has no concept of "classes" at runtime. You cannot ask an object for its class, but you can ask it for its prototype.

A note on performance

Creating many objects that inherit from a prototype is very fast. The objects themselves are very leightweight. The common behavior is stored in a single prototype object that is shared by all instances.

Here is a performance comparison with the revealing module pattern, where all methods are re-created for every single object.

Native ES6 classes are not faster than their transpiled prototype form. Since the browser's JavaScript VM automatically convert the ES6 version to the prototype version, they're literally the same.

Object literals vs. Object.create()

All of us have used an object literal to create an object:

Copy
let x = {}

This is equivalent to the following:

Copy
let x = Object.create(Object.prototype)

From Object.prototype you will inherit some methods common to most objects, such as x.toString() or x.hasOwnProperty().

If you want to create an entirely empty object without any properties, you can use:

Copy
let x = Object.create(null)

Compare this to Object vs. BasicObject in Ruby:

BasicObject can be used for creating object hierarchies independent of Ruby’s object hierarchy, proxy objects like the Delegator class, or other uses where namespace pollution from Ruby’s methods and classes must be avoided.

prototype property vs. Object.getPrototypeOf()

The following is maybe the most confusing thing about JavaScript OOP: JavaScript has two very different concepts that are both called "prototype":

Only functions have a child prototype

Only functions have an automatic { prototype } property, just in case they are used as a constructor function. Properties in this prototype will be passed down to objects constructed with that function, which is why we will call it the child prototype.

The default child prototype returns an object with a constructor property pointing back to the function:

Copy
function foo() {} foo.prototype // => { constructor: foo }

Non-function objects have no { prototype } property!

All objects have a parent prototype

Object.getPrototypeOf(object) returns the prototype from which an object inherits, which is why we will call it the parent prototype.

Copy
let object = new foo() object.prototype // => undefined, only functions have a child prototype! Object.getPrototypeOf(object) // => foo.prototype, which is { constructor: foo } object.constructor // => function foo() {}

Only functions have both a parent prototype and child prototype

Functions are the only values that have both a parent prototype and a child prototype:

Copy
function fn() {} fn.prototype // returns the child prototype: { constructor: fn } Object.getPrototypeOf(fn) // returns the parent prototype: Function.prototype

Function.prototype contains the instance methods of all functions, such as fn.call(), fn.name, fn.bind() or fn.length (arity).

Other ways to access the parent prototype

You might encounter other ways to access an object's parent prototype:

  1. Every object has a non-standard { __proto__ } property. This is the same object that is returned by Object.getPrototypeOf(o). Prefer Object.getPrototypeOf(o), since that's a JavaScript standard.
  2. There is Object.setPrototypeOf(o, newProto) to change an object's inheritance parent. Changing prototypes after object construction is slow in every JavaScript implementation. Avoid doing that at once your app has booted.
  3. Finally, there is Reflect.getPrototypeOf(o). For all practical cases this behaves like Object.getPrototypeOf(o).

Inheriting from another class

In ES6 we may use the extends keyword to inherit from another class:

Copy
class Student extends User { constructor(firstName, lastName, studentNumber) { super(firstName, lastName) this.studentNumber = studentNumber } isEnrolled() { return typeof this.studentNumber === 'number' } }

The browser will translate this into code similiar to this:

Copy
function Student(firstName, lastName, studentNumber) { // Call superclass constructor User.call(this, firstName, lastName) this.studentNumber = studentNumber } // Have our own child prototype that inherits all properties form User's // child prototype and may then be extended with Student-specific methods. Student.prototype = Object.create(User.prototype) Student.prototype.isEnrolled = function() { return typeof this.studentNumber === 'number' }

Let's play with our new Student class:

Copy
let max = new Student('Max', 'Muster', 1234) max.prototype // => undefined, only functions have a child prototype! max.constructor // => Student Object.getPrototypeOf(max) // => Student.prototype Object.getPrototypeOf(Object.getPrototypeOf(max)) // => User.prototype Student.prototype // => { constructor: Student, isEnrolled: function() { ... } } Student.prototype.prototype // => undefined, only functions have a child prototype! Object.getPrototypeOf(Student.prototype) // => User.prototype

Overriding methods and calling the super class

When we override an inherited method, ES6 let's us use super to access the implementation of our parent prototype:

Copy
class Parent { foo() { console.log("this is parent") } } class Child extends Foo { foo() { super.foo() console.log("this is child") } } let child = new Child() child.foo() // logs "this is parent", then log "this is child"

How would we access a superclass method as a child class in a hand-rolled protoype implementation?

Remember that in JavaScript we can call any function with any object as this. Hence we can call Parent.prototype.foo on our Child instance:

Copy
function Parent() {} Parent.prototype.foo = function() { console.log("this is parent") } function Child() {} Child.prototype = Object.create(Parent.prototype) Child.prototype.foo = function() { Parent.prototype.foo.call(this) console.log("this is child") }

Class methods ("static methods")

Remember that functions are objects with properties?

Class methods are properties of the constructor function:

Copy
function Constructor() { ... } Constructor.prototype.instanceMethod = function() { ... } Constructor.classMethod = function() { ... } new Constructor().instanceMethod() Constructor.classMethod()

Since instances inherit a { constructor } property from their parent prototype, instance methods may access class properties through this.constructor.classProperty:

Copy
Constructor.prototype.instanceMethod = function() { this.constructor.classMethod() }

ES6 gives us the static keyword to define a class method with less code:

Copy
class Constructor { instanceMethod() { ... } static classMethod() { ... } }

Again the JavaScript VM will silently convert a static method to a function property on the constructor functions, as JavaScript has no concept of classes at runtime.

In ES6, class methods are inherited

In an ES6 class, class methods are inherited from our superclass:

Copy
class Parent { static fromParent() { console.log('static method from parent') } } class Child extends Parent { static fromChild() { console.log('static method from child') } } Child.fromChild() // logs "static method from child" Child.fromParent() // logs "static method from parent"

That's seems surprising at first, because for that the functions Child and Parent would need to be in an inheritance chain. And in fact they are! Transpilers like Babel use Object.setPrototypeOf(Child, Parent) to achieve that:

Copy
function Parent() {} Parent.fromParent = function() { console.log('static method from parent') } function Child() {} Child.fromChild = function() { console.log('static method from child') } // Inherit instance properties from Parent.prototype Child.prototype = Object.create(Parent.prototype) // Inherit static properties from Parent. // To do so we change Child's parent from Function.prototype to Parent: Object.setPrototypeOf(Child, Parent)

Note how we now have two separate prototype chains:

  1. For class properties, Child inherits from Parent inherits from Function.prototype
  2. For instance properties, Child.prototype inherits from Parent.prototype inherits from Object.prototype

Image

Reveal

Note that Object.getPrototypeOf(Parent) is Function.prototype, not Object.

Exercise: Multiple inheritance

Ruby supports multiple inheritance through modules and include:

Copy
module Loggable def log(message) let prefix = "[" + self.class.name + "] " puts prefix + message end end class Foo def foo puts "this is foo" end end class Bar < Foo include Loggable def bar puts "this is bar" end end bar = Bar.new bar.foo # prints "this is foo" bar.bar # prints "this is bar" bar.log("debug info") # prints "[Bar] debug info"

ES6 does not have a keyword like include. However, now that we know how classes work internally, we can build a mix() function that works similiarly:

Copy
Loggable = { log: function(message) { let prefix = "[" + this.constructor.name + "] " console.log(prefix + message) } } class Foo { foo() { console.log("this is foo") } } class Bar extends mix(Foo, Loggable) { bar() { console.log("this is bar") } } let bar = new Bar() bar.foo() // "this is foo" bar.bar() // "this is bar" bar.log("debug info") // logs "[Bar] debug info" function mix(parent, module) { // ??? }

Here is one way to implement mix():

Copy
function mix(parent, module) { let intermediate = function() {} // Inherit class methods Object.setPrototypeOf(intermediate, parent) // Inherit instance methods intermediate.prototype = Object.create(parent.prototype) // Copy module methods into our prototype Object.assign(intermediate.prototype, module) return intermediate }

We can also leverage the class and extends keywords to automatically wire our two prototype chains:

Copy
function mix(parent, module) { class intermediate extends parent {} Object.assign(intermediate.prototype, module) return intermediate }

Level 4: Properties

Earlier in this talk I mentioned that most JavaScript objects behave like a dumb Hash in Ruby and less like an actual Object.

However, that is not true for all JavaScript objects. A JavaScript property may have various modifiers that change how the property behaves. The set of modifiers that make up a property's behavior is called a property descriptor.

The following modifiers dictate how a stored property behaves:

Attribute Effect Default
value The property's current value. undefined
writable Whether the property may be assigned a new value. true
configurable Whether this property descriptor may be changed after creation. true
enumerable Whether this property appears in enumerations, like for ... in. true

When we define a property on an object, we get a default property descriptor:

Copy
let obj = { foo: 'bar' } Object.getOwnPropertyDescriptor(obj, 'foo') // => { value: 'bar', writable: true, configurable: true, enumerable: true }

To define a property with a non-default property descriptor, use Object.defineProperty(object, key, descriptor).

Let's use that to define a read-only property user.name:

Copy
let user = {} Object.defineProperty(user, 'name', { value: 'Max', writable: false }) user.name // => 'Max' user.name = 'Anna' // user.name // => 'Max'

Defining computed properties

You may also define a property whose value is computed instead of stored. Such a property has the following modifiers (instead of value):

Attribute Effect
get A function that is called when the property is read ("getter")
set A function that is called when the property is written ("Setter")

Here is an example for a computed property { fullName }:

Copy
let user = { firstName: 'Max', lastName: 'Muster' } Object.defineProperty(user, 'fullName', { get: function() { return this.firstName + ' ' + this.lastName }, set: function(value) { [this.firstName, this.lastName] = value.split(' ') } }) user.fullName // => 'Max Muster' user.fullName = 'Berta Beispiel' user.firstName // => 'Berta' user.lastName // => 'Beispiel'

To access a property's descriptor (instead of getting its value), use Object.getOwnPropertyDescriptor(object, key):

Copy
user.fullName // => 'Max Muster' Object.getOwnPropertyDescriptor(object, 'fullName') // => { get: function() { ... }, set: function() { ... } }

Enumerability

An enumerable property appears in enumerations, like for...in or Object.keys(o). Properties are enumerable by default. You can use Object.defineProperty() to make a property non-enumerable.

A good default is to make stored attributes enumerable, but to make methods non-enumerable. Think ActiveRecord's attributes hash vs. all methods of an ActiveRecord instance.

Let's take an object that is a mix of stored values and computed methods:

Copy
let user = { firstName: 'Max', lastName: 'Muster', fullName: function() { return this.firstName + ' ' + this.lastName } }

Copying user includes the fullName() method in the copy, which you might not want:

Copy
let copy = {} Object.assign(copy, user) // => { "firstName": "Max", "lastName": "Muster", fullName: function() { ... } }

If we make the fullName() method non-enumerable it will disappear from copies and loops:

Copy
Object.defineProperty(user, 'fullName', { enumerable: false }) let copy2 = {} Object.assign(copy2, user) // => { "firstName": "Max", "lastName": "Muster" }

Method properties defined in an ES6 class are not enumerable by default.

There is no consistency how JS features honor property metadata

Every JavaScript feature has its own rules whether it respects inheritance or enumerability.

For example, Object.keys() lists only the own enumerable properties of an object, but ignores inherited properties:

Copy
let parent = { parentKey: 1 } let child = Object.create(parent) child.childKey = 2 Object.keys(child) // => ['childKey']

On the other hand, for...in also iterates over inherited properties:

Copy
for (let key in child) { console.log(key) // prints 'childKey', then 'parentKey' }

There is an epic table on MDN that lists all the property-related functions and how they behave.
You can print it out as a poster and pin it next to your JavaScript equality matrix:

Thank you!

Any questions?

You can reach me at:

These slides will be made available under https://makandracards.com/makandra/481040

Don't miss future Full Stack Hour events by joining our Meetup group:
https://www.meetup.com/de-DE/Full-Stack-Hour-by-makandra/

Once an application no longer requires constant development, it needs periodic maintenance for stable and secure operation. makandra offers monthly maintenance contracts that let you focus on your business while we make sure the lights stay on.

Owner of this card:

Avatar
Henning Koch
Last edit:
about 1 month ago
by Henning Koch
Attachments:
2020-06_Prototype_Chains_(5)_(1).png, 2020-06_Prototype_Chains_(5).drawio
About this deck:
We are makandra and do test-driven, agile Ruby on Rails software development.
License for source code
Posted by Henning Koch to makandra dev
This website uses short-lived cookies to improve usability.
Accept or learn more