Native Classes In-Depth

Native classes were first added to JavaScript in ES2015 (also known as ES6). They are defined using the class keyword, and look like this:

class Person {
  helloWorld() {
    console.log('Hello, world!');
  }
}

This guide will go over the basics of classes, along with two new features that are still in development in JavaScript: class fields and decorators. We use these features in Ember because they are very useful and make writing class code much easier, and they have made it far enough along the process of being added to JavaScript to depend on in production applications.

Defining Classes

Classes are defined using the class keyword:

class Person {}

Once defined, a class exists like a variable does in the current scope:

function definePerson() {
  class Person {}
  console.log(Person);
}

definePerson(); // class Person {}
console.log(Person); // Error: Person is not defined

You can choose not to give your class a name, making it an anonymous class. For instance, you could do a default export like this, but it is not recommended:

// Not recommended ????
export default class {}

The reasons being:

  1. Giving your class a name makes it easier to search for in general, and is better for code editors and documentation tools.
  2. Giving your class a name gives it a name in the debugger, making your life easier later on.

You can create a new instance of the class using the new keyword:

let tom = new Person();

Instances are like Plain Old JavaScript Objects (POJOs) in many ways. You can assign values to them however you like, and generally treat them the same:

let tom = new Person();
let yehuda = {};

tom.name = 'Tom Dale';
yehuda.name = 'Yehuda Katz';

console.log(tom); // Person {name: "Tom Dale"}
console.log(yehuda); // {name: "Yehuda Katz"}

The difference is that instances of classes inherit elements that are defined in the class definition. For instance, we can define a method on the person class, and then call it from the instance:

class Person {
  helloWorld() {
    console.log(`${this.name} says: Hello, world!`);
  }
}

let tom = new Person();
tom.name = 'Tom Dale';
tom.helloWorld(); // Tom Dale says: Hello, world!

This allows you to define different kinds of objects, which have their own methods, properties, fields, and more. This is essentially Object Oriented Programming - you define different types of objects that handle different problems and concerns, keeping your code organized.

Note: Object Oriented Programming is a fundamental part of JavaScript, but it's not the only part - JavaScript is a multi-paradigm language, and supports Object Oriented Programming patterns along with Functional Programming, Event Driven programming, and imperative programming. You may see strong adherents to different styles both inside and outside of the Ember ecosystem, and that's OK! JavaScript is flexible, and allows you to choose the patterns that work well for you, so don't feel like all of your code needs to be written in a class, and likewise, don't feel like everything needs to be a function.

There are 4 major types of elements that can be defined in a class:

  • The constructor function
  • Methods
  • Fields
  • Accessors, also known as getters and setters

Along with two types of modifiers that can be applied to methods, accessors, and fields:

  • static
  • Decorators

Constructor

The constructor method is a special method in classes. It's run when you create a new instance of the class, and can be used to setup the class:

class Person {
  constructor() {
    this.name = 'Tom Dale';
  }
}

let tom = new Person();
console.log(tom.name); // 'Tom Dale'

You can also pass arguments to the constructor when creating instances with new:

class Person {
  constructor(name) {
    this.name = name;
  }
}

let tom = new Person('Tom Dale');
console.log(tom.name); // 'Tom Dale'

The constructor can't be called in any other way. It doesn't exist on the class or instances:

class Person {
  constructor(name) {
    this.name = name;
  }
}

let tom = new Person('Tom Dale');
console.log(tom.constructor()); // Error: undefined is not a function

Methods

Methods are functions that are defined on the class, and usable by instances:

class Person {
  constructor(name) {
    this.name = name;
  }

  helloWorld() {
    console.log(`${this.name} says: Hello, world!`);
  }
}

let stefan = new Person('Stefan Penner');
stefan.helloWorld(); // Stefan Penner says: Hello, world!

Like functions declared on objects, they can access the instance using this, so they can store and access variables on the instance.

Methods do not exist on the class itself by default:

class Person {
  helloWorld() {
    console.log('Hello, world!');
  }
}

Person.helloWorld(); // Error: undefined is not a function

They exist on the class's prototype, and are only readily callable by instances. However, they can be added to the class directly using the static keyword, which is described in more detail below.

Note: if you don't know what a "prototype" is, don't worry - it's how JavaScript does inheritance. Most of the details of prototypes are made simpler by native class syntax, and while it's useful to know, you don't need to dig into them to continue learning Ember or to be productive. If you are curious about them, you can check out the MDN docs for more details.

Fields

Class fields allow you to assign properties to an instance of the class on construction. You can define a field like this:

class Person {
  name = 'Yehuda Katz';
}

This is the very similar to defining the Person class with a constructor like this:

class Person {
  constructor() {
    this.name = 'Yehuda Katz';
  }
}

Class fields are somewhat like object properties, but they have some key differences. They are created and assigned to every instance of the class, meaning that instance gets a unique version of the field. This doesn't matter if the field is a primitive, like a string or a number, but does matter if it's an object or an array:

class Person {
  friends = [];
}

let tom = new Person();
let yehuda = new Person();

tom.friends === yehuda.friends;
// false, they're different arrays

Fields can also access the class instance using this when they are being assigned:

class Child {
  constructor(parent) {
    this.parent = parent;
  }
}

class Parent {
  child = new Child(this);
}

However, relying on state should generally be avoided in field initializers, since it can make your classes brittle and error prone, especially when refactoring:

// Avoid this ????
class Person {
  title = 'Prof.';
  name = 'Tomster';

  fullName = `${this.title} ${this.name}`;
}

// because it breaks if you change the order
class Person {
  fullName = `${this.title} ${this.name}`;

  title = 'Prof.';
  name = 'Tomster';
}

let yehuda = new Person();
console.log(yehuda.fullName); // undefined undefined

// This is ok, works no matter what the order is ✅
class Person {
  constructor() {
    this.fullName = `${this.title} ${this.name}`;
  }

  title = 'Prof.';
  name = 'Tomster';
}

Fields are assigned before any code in the constructor method is run, which is why we can rely on them being assigned correctly by the time it runs. As with methods, fields do not exist on the class itself, nor do they exist on the class's prototype, they only exist on the instance of the class. However, they can be added to the class directly using the static keyword, which is described in more detail below.

Accessors

Accessors, also known as getters/setters, allow you to define a special function that is accessed like a property. For example:

class Person {
  get name() {
    return 'Melanie Sumner';
  }
}

let melanie = new Person();
console.log(melanie.name); // 'Melanie Sumner'

Even though get name is a method, we can treat it like a normal property. However, if we try to set the name property to a new value, we get an error:

melanie.name = 'Melanie Sumner';
// Cannot set property name of #<Person> which has only a getter

We need to add a setter in order to be able to set it. Generally, the setter function stores the value somewhere, and the getter function retrieves it:

class Person {
  _name = 'Melanie Sumner';

  get name() {
    return this._name;
  }

  set name(newName) {
    this._name = newName;
  }
}

let melanie = new Person();
console.log(melanie.name); // 'Melanie Sumner'
console.log(melanie._name); // 'Melanie Sumner'

melanie.name = 'Melanie Autumn';
console.log(melanie.name); // 'Melanie Autumn'
console.log(melanie._name); // 'Melanie Autumn'

Getters can also be used on their own to calculate values dynamically:

class Person {
  title = 'Dr.';
  name = 'Zoey';

  get fullName() {
    return `${this.title} ${this.name}`;
  }
}

These values are recalculated every time the property is accessed:

class Counter {
  _count = 0;

  get count() {
    return this._count++;
  }
}

let counter = new Counter();
console.log(counter.count); // 0
console.log(counter.count); // 1
console.log(counter.count); // 2

This is why getters should generally avoid mutating state on the instance, and you should be aware of their performance cost since they'll rerun the code every time.

Like methods, accessors do not exist on the class itself, and instead are on the class prototype. As such, they are only readily accessible on instances of the class. However, they can be added to the class directly using the static keyword, which is described in more detail below.

static

As we mentioned above, for all intents and purposes the methods, fields, and accessors are only usable on instances of the class. However, sometimes you may want to place them directly on the class, for instance if you want to share some state between all instances of the class. You can do this by adding the static keyword in front of the definition:

class Vehicle {
  constructor() {
    Vehicle.incrementCount();
  }

  static incrementCount() {
    this.count++;
  }

  static count = 0;
}

console.log(Vehicle.count); // 0

let car = new Vehicle();

console.log(Vehicle.count); // 1

Static class elements are not available on instances, and are only available directly on the class itself.

class Alert {
  static helloWorld() {
    return 'Hello, world!';
  }
}

console.log(Alert.helloWorld()); // Hello, world!

let alert = new Alert();

console.log(alert.helloWorld()); // Error: undefined is not a function

Decorators

Decorators are user defined modifiers that can be applied to a class or class element such as a field or method to change its behavior. For instance, you could create a @cache decorator that caches the return value of a getter the first time it is calculated:

import { cache } from 'my-cache-decorator';

class Counter {
  _count = 0;

  @cache
  get count() {
    return this._count++;
  }
}

let counter = new Counter();

console.log(counter.count); // 0
console.log(counter.count); // 0

Decorators are normal JavaScript functions that get applied with a special syntax, which is why you import them like any other function, but you use the @ symbol when applying them. Decorators come in a variety of flavors, and some can be applied to class's directly as well:

@observable
class Person {}

Some decorators can also receive arguments:

class Person {
  fullName = 'Matthew Beale';

  @alias('fullName') name;
}

let matt = new Person();
console.log(matt.name); // Matthew Beale

Ember provides a number of decorators, such as the @tracked decorator, that will be described in greater detail later on in the guides.

Note: Decorators are still being actively developed in JavaScript, which means that there may be small changes in the future. The decorators provided by Ember should remain stable through these changes, but it is recommended that you exercise caution if using any external decorator libraries which may not have the same stability guarantees.

Extending Classes

You can create classes that extend existing classes, inheriting all of their elements, using the extends keyword:

class Vehicle {
  move() {
    console.log('moving!');
  }
}

class Aircraft extends Vehicle {
  fly() {
    console.log('flying!');
  }
}

let airbus = new Aircraft();
airbus.move(); // moving!
airbus.fly(); // flying!

Static class elements are also inherited this way:

class Vehicle {
  static count = 0;
}

class Aircraft extends Vehicle {
  static id = 1;
}

console.log(Aircraft.count); // 0
console.log(Aircraft.id); // 1

Defining subclasses is otherwise the same as defining a base class in most ways, with the exception of the constructor function where you must use the super keyword (discussed in more detail below). Class elements that are redefined by the child class will be overridden, and their values will be fully replaced on the child:

class Vehicle {
  move() {
    console.log('moving');
  }
}

class Aircraft extends Vehicle {
  move() {
    console.log('flying!');
  }
}

let airbus = new Aircraft();
airbus.move(); // flying!

However, child classes can use the super keyword to access the parent, and use its methods and accessors. Class fields are always overwritten on the instance, so the values on the parent class cannot be accessed by the child if they are redefined.

constructor in extends

When extending a class, if you define a constructor function you must call super in the constructor, and you must do it before you access the class with this. This will call the parent class's constructor, ensuring that the class is setup properly:

class Vehicle {
  constructor() {
    console.log('vehicle made!');
  }
}

class Aircraft extends Vehicle {
  constructor() {
    super();
    console.log('aircraft made!');
  }
}

let airbus = new Aircraft();
// vehicle made!
// aircraft made!

In general, it's a good idea to pass along any arguments to the parent class in the call to super, since they'll probably be necessary for setting up the class.

class TodoComponent extends Component {
  constructor() {
    super(...arguments);

    // setup the component...
  }
}

Using super

super must be used in subclass constructors, but it can also be used in other class methods or accessors. When being used in any other method, you must explicitly specify the method you're calling on the super class:

class Vehicle {
  move() {
    console.log(`moving!`);
  }
}

class Aircraft extends Vehicle {
  move() {
    super.move();
    console.log('flying!');
  }
}

let airbus = new Aircraft();
airbus.move(); // moving! flying!

You can also call different methods on the super class if you want, allowing you to change behaviors or alias methods:

class Vehicle {
  moveType = 'moving';

  move() {
    console.log(`${this.moveType}!`);
  }
}

class Aircraft extends Vehicle {
  moveType = 'flying';

  fly() {
    super.move();
  }
}

let airbus = new Aircraft();
airbus.fly(); // flying!

If the method does not exist on the parent class, it will throw an error:

class Vehicle {
  moveType = 'moving';

  move() {
    console.log(`${this.moveType}!`);
  }
}

class Aircraft extends Vehicle {
  moveType = 'flying';

  fly() {
    super.fly();
  }
}

let airbus = new Aircraft();
airbus.fly(); // Error: undefined is not a function

In certain cases, you will want to pass arguments to the super method before or after overriding. This allows the super class method to continue operating as it normally would.

One common example is when overriding the normalizeResponse() hook in one of Ember Data's serializers.

A handy shortcut for this is to use a "spread operator", like ...arguments:

normalizeResponse(store, primaryModelClass, payload, id, requestType)  {
  // Customize my JSON payload for Ember-Data
  return super.normalizeResponse(...arguments);
}

The above example returns the original arguments (after your customizations) back to the parent class, so it can continue with its normal operations.

© 2020 Yehuda Katz, Tom Dale and Ember.js contributors
Licensed under the MIT License.
https://guides.emberjs.com/v3.25.0/in-depth-topics/native-classes-in-depth