How We Organize in JS

Two major patterns for organizing code (data and behavior) are used broadly across the JS ecosystem: classes and modules. These patterns are not mutually exclusive; many programs can and do use both. Other programs will stick with just one pattern, or even neither!

In some respects, these patterns are very different. But interestingly, in other ways, they’re just different sides of the same coin. Being proficient in JS requires understanding both patterns and where they are appropriate (and not!).

Classes

The terms “object-oriented,” “class-oriented,” and “classes” are all very loaded full of detail and nuance; they’re not universal in definition.

We will use a common and somewhat traditional definition here, the one most likely familiar to those with backgrounds in “object-oriented” languages like C++ and Java.

A class in a program is a definition of a “type” of custom data structure that includes both data and behaviors that operate on that data. Classes define how such a data structure works, but classes are not themselves concrete values. To get a concrete value that you can use in the program, a class must be instantiated (with the new keyword) one or more times.

Consider:

  1. class Page {
  2. constructor(text) {
  3. this.text = text;
  4. }
  5. print() {
  6. console.log(this.text);
  7. }
  8. }
  9. class Notebook {
  10. constructor() {
  11. this.pages = [];
  12. }
  13. addPage(text) {
  14. var page = new Page(text);
  15. this.pages.push(page);
  16. }
  17. print() {
  18. for (let page of this.pages) {
  19. page.print();
  20. }
  21. }
  22. }
  23. var mathNotes = new Notebook();
  24. mathNotes.addPage("Arithmetic: + - * / ...");
  25. mathNotes.addPage("Trigonometry: sin cos tan ...");
  26. mathNotes.print();
  27. // ..

In the Page class, the data is a string of text stored in a this.text member property. The behavior is print(), a method that dumps the text to the console.

For the Notebook class, the data is an array of Page instances. The behavior is addPage(..), a method that instantiates new Page pages and adds them to the list, as well as print() (which prints out all the pages in the notebook).

The statement mathNotes = new Notebook() creates an instance of the Notebook class, and page = new Page(text) is where instances of the Page class are created.

Behavior (methods) can only be called on instances (not the classes themselves), such as mathNotes.addPage(..) and page.print().

The class mechanism allows packaging data (text and pages) to be organized together with their behaviors (e.g., addPage(..) and print()). The same program could have been built without any class definitions, but it would likely have been much less organized, harder to read and reason about, and more susceptible to bugs and subpar maintenance.

Class Inheritance

Another aspect inherent to traditional “class-oriented” design, though a bit less commonly used in JS, is “inheritance” (and “polymorphism”). Consider:

  1. class Publication {
  2. constructor(title,author,pubDate) {
  3. this.title = title;
  4. this.author = author;
  5. this.pubDate = pubDate;
  6. }
  7. print() {
  8. console.log(`
  9. Title: ${ this.title }
  10. By: ${ this.author }
  11. ${ this.pubDate }
  12. `);
  13. }
  14. }

This Publication class defines a set of common behavior that any publication might need.

Now let’s consider more specific types of publication, like Book and BlogPost:

  1. class Book extends Publication {
  2. constructor(bookDetails) {
  3. super(
  4. bookDetails.title,
  5. bookDetails.author,
  6. bookDetails.publishedOn
  7. );
  8. this.publisher = bookDetails.publisher;
  9. this.ISBN = bookDetails.ISBN;
  10. }
  11. print() {
  12. super.print();
  13. console.log(`
  14. Publisher: ${ this.publisher }
  15. ISBN: ${ this.ISBN }
  16. `);
  17. }
  18. }
  19. class BlogPost extends Publication {
  20. constructor(title,author,pubDate,URL) {
  21. super(title,author,pubDate);
  22. this.URL = URL;
  23. }
  24. print() {
  25. super.print();
  26. console.log(this.URL);
  27. }
  28. }

Both Book and BlogPost use the extends clause to extend the general definition of Publication to include additional behavior. The super(..) call in each constructor delegates to the parent Publication class’s constructor for its initialization work, and then they do more specific things according to their respective publication type (aka, “sub-class” or “child class”).

Now consider using these child classes:

  1. var YDKJS = new Book({
  2. title: "You Don't Know JS",
  3. author: "Kyle Simpson",
  4. publishedOn: "June 2014",
  5. publisher: "O'Reilly",
  6. ISBN: "123456-789"
  7. });
  8. YDKJS.print();
  9. // Title: You Don't Know JS
  10. // By: Kyle Simpson
  11. // June 2014
  12. // Publisher: O'Reilly
  13. // ISBN: 123456-789
  14. var forAgainstLet = new BlogPost(
  15. "For and against let",
  16. "Kyle Simpson",
  17. "October 27, 2014",
  18. "https://davidwalsh.name/for-and-against-let"
  19. );
  20. forAgainstLet.print();
  21. // Title: For and against let
  22. // By: Kyle Simpson
  23. // October 27, 2014
  24. // https://davidwalsh.name/for-and-against-let

Notice that both child class instances have a print() method, which was an override of the inherited print() method from the parent Publication class. Each of those overridden child class print() methods call super.print() to invoke the inherited version of the print() method.

The fact that both the inherited and overridden methods can have the same name and co-exist is called polymorphism.

Inheritance is a powerful tool for organizing data/behavior in separate logical units (classes), but allowing the child class to cooperate with the parent by accessing/using its behavior and data.

Modules

The module pattern has essentially the same goal as the class pattern, which is to group data and behavior together into logical units. Also like classes, modules can “include” or “access” the data and behaviors of other modules, for cooperation’s sake.

But modules have some important differences from classes. Most notably, the syntax is entirely different.

Classic Modules

ES6 added a module syntax form to native JS syntax, which we’ll look at in a moment. But from the early days of JS, modules was an important and common pattern that was leveraged in countless JS programs, even without a dedicated syntax.

The key hallmarks of a classic module are an outer function (that runs at least once), which returns an “instance” of the module with one or more functions exposed that can operate on the module instance’s internal (hidden) data.

Because a module of this form is just a function, and calling it produces an “instance” of the module, another description for these functions is “module factories”.

Consider the classic module form of the earlier Publication, Book, and BlogPost classes:

  1. function Publication(title,author,pubDate) {
  2. var publicAPI = {
  3. print() {
  4. console.log(`
  5. Title: ${ title }
  6. By: ${ author }
  7. ${ pubDate }
  8. `);
  9. }
  10. };
  11. return publicAPI;
  12. }
  13. function Book(bookDetails) {
  14. var pub = Publication(
  15. bookDetails.title,
  16. bookDetails.author,
  17. bookDetails.publishedOn
  18. );
  19. var publicAPI = {
  20. print() {
  21. pub.print();
  22. console.log(`
  23. Publisher: ${ bookDetails.publisher }
  24. ISBN: ${ bookDetails.ISBN }
  25. `);
  26. }
  27. };
  28. return publicAPI;
  29. }
  30. function BlogPost(title,author,pubDate,URL) {
  31. var pub = Publication(title,author,pubDate);
  32. var publicAPI = {
  33. print() {
  34. pub.print();
  35. console.log(URL);
  36. }
  37. };
  38. return publicAPI;
  39. }

Comparing these forms to the class forms, there are more similarities than differences.

The class form stores methods and data on an object instance, which must be accessed with the this. prefix. With modules, the methods and data are accessed as identifier variables in scope, without any this. prefix.

With class, the “API” of an instance is implicit in the class definition—also, all data and methods are public. With the module factory function, you explicitly create and return an object with any publicly exposed methods, and any data or other unreferenced methods remain private inside the factory function.

There are other variations to this factory function form that are quite common across JS, even in 2020; you may run across these forms in different JS programs: AMD (Asynchronous Module Definition), UMD (Universal Module Definition), and CommonJS (classic Node.js-style modules). The variations are minor (not quite compatible). However, all of these forms rely on the same basic principles.

Consider also the usage (aka, “instantiation”) of these module factory functions:

  1. var YDKJS = Book({
  2. title: "You Don't Know JS",
  3. author: "Kyle Simpson",
  4. publishedOn: "June 2014",
  5. publisher: "O'Reilly",
  6. ISBN: "123456-789"
  7. });
  8. YDKJS.print();
  9. // Title: You Don't Know JS
  10. // By: Kyle Simpson
  11. // June 2014
  12. // Publisher: O'Reilly
  13. // ISBN: 123456-789
  14. var forAgainstLet = BlogPost(
  15. "For and against let",
  16. "Kyle Simpson",
  17. "October 27, 2014",
  18. "https://davidwalsh.name/for-and-against-let"
  19. );
  20. forAgainstLet.print();
  21. // Title: For and against let
  22. // By: Kyle Simpson
  23. // October 27, 2014
  24. // https://davidwalsh.name/for-and-against-let

The only observable difference here is the lack of using new, calling the module factories as normal functions.

ES Modules

ES modules (ESM), introduced to the JS language in ES6, are meant to serve much the same spirit and purpose as the existing classic modules just described, especially taking into account important variations and use cases from AMD, UMD, and CommonJS.

The implementation approach does, however, differ significantly.

First, there’s no wrapping function to define a module. The wrapping context is a file. ESMs are always file-based; one file, one module.

Second, you don’t interact with a module’s “API” explicitly, but rather use the export keyword to add a variable or method to its public API definition. If something is defined in a module but not exported, then it stays hidden (just as with classic modules).

Third, and maybe most noticeably different from previously discussed patterns, you don’t “instantiate” an ES module, you just import it to use its single instance. ESMs are, in effect, “singletons,” in that there’s only one instance ever created, at first import in your program, and all other imports just receive a reference to that same single instance. If your module needs to support multiple instantiations, you have to provide a classic module-style factory function on your ESM definition for that purpose.

In our running example, we do assume multiple-instantiation, so these following snippets will mix both ESM and classic modules.

Consider the file publication.js:

  1. function printDetails(title,author,pubDate) {
  2. console.log(`
  3. Title: ${ title }
  4. By: ${ author }
  5. ${ pubDate }
  6. `);
  7. }
  8. export function create(title,author,pubDate) {
  9. var publicAPI = {
  10. print() {
  11. printDetails(title,author,pubDate);
  12. }
  13. };
  14. return publicAPI;
  15. }

To import and use this module, from another ES module like blogpost.js:

  1. import { create as createPub } from "publication.js";
  2. function printDetails(pub,URL) {
  3. pub.print();
  4. console.log(URL);
  5. }
  6. export function create(title,author,pubDate,URL) {
  7. var pub = createPub(title,author,pubDate);
  8. var publicAPI = {
  9. print() {
  10. printDetails(pub,URL);
  11. }
  12. };
  13. return publicAPI;
  14. }

And finally, to use this module, we import into another ES module like main.js:

  1. import { create as newBlogPost } from "blogpost.js";
  2. var forAgainstLet = newBlogPost(
  3. "For and against let",
  4. "Kyle Simpson",
  5. "October 27, 2014",
  6. "https://davidwalsh.name/for-and-against-let"
  7. );
  8. forAgainstLet.print();
  9. // Title: For and against let
  10. // By: Kyle Simpson
  11. // October 27, 2014
  12. // https://davidwalsh.name/for-and-against-let
NOTE:
The as newBlogPost clause in the import statement is optional; if omitted, a top-level function just named create(..) would be imported. In this case, I’m renaming it for readability’s sake; its more generic factory name of create(..) becomes more semantically descriptive of its purpose as newBlogPost(..).

As shown, ES modules can use classic modules internally if they need to support multiple-instantiation. Alternatively, we could have exposed a class from our module instead of a create(..) factory function, with generally the same outcome. However, since you’re already using ESM at that point, I’d recommend sticking with classic modules instead of class.

If your module only needs a single instance, you can skip the extra layers of complexity: export its public methods directly.