A walk-through of some of the API decisions to consider when writing a JavaScript library.
Whether you are writing your first open-source library or developing a private repo to benefit multiple teams at your company, sharing code is one of the most rewarding things to do as a developer and it is a practice at the heart of the JavaScript community.
We will be doing a walk-through of the components of a JavaScript library including some options for structuring its Public API, as well as tools for controlling its data access and mutation. After comparing these options, we’ll look at which of these patterns some well-known JavaScript libraries choose to follow.
Let’s say we have some valuable tool that we want to share with the larger community. Here we’ll use the example of a tool for calculating a credit score from a user’s financial accounts.
It could look something like this:
// CreditCalculator.js
export default function CreditCalculator(bankAccounts, creditCards) {
this.creditCards = creditCards;
this.bankAccounts = bankAccounts;
this.creditCardAnalyzer = function creditCardAnalyzer() {
// Do math...
}
this.bankAccountAnalyzer = function bankAccountAnalyzer() {
// Do more math...
}
this.calculateScore = function calculateScore() {
// I'm sure it's just this simple...
return this.creditCardAnalyzer() + this.bankAccountAnalyzer();
}
});
// client.js
import CreditCalculator from 'CreditCalculator';
const creditCalculator = new CreditCalculator(
['Chase', 'Wells Fargo'], ['Visa', 'MasterCard']
);
creditCalculator.calculateScore(); // 800
Our core functionality is finished! Our tool is able to calculate credit scores based on a user’s bank accounts and credit cards. We publish it up to NPM and receive lots of downloads and positive feedback from initial users, hurray!
But a little while later, we start getting interesting questions from consumers of the library who are playing around with it:
Let’s classify each of these problems:
To answer the first question, we need to control what functionality we want to expose to the client. Currently, all of our internal properties are exposed, which makes it too easy for them to use the module incorrectly.
What are these analyzer methods I see? Should I be using them?
The first, and simplest thing we can do to differentiate our public and private implementation is to use underscore prefixing.
This is a common pattern in JS libraries, and is simple to do:
export default function CreditCalculator(bankAccounts, creditCards) {
this.creditCards = creditCards;
this.bankAccounts = bankAccounts;
this._creditCardAnalyzer = function creditCardAnalyzer() {
// Do math...
}
this._bankAccountAnalyzer = function bankAccountAnalyzer() {
// Do more math...
}
this.calculateScore = function calculateScore() {
return this.creditCardAnalyzer() + this.bankAccountAnalyzer();
}
});
Some library maintainers find this, along with private or deprecated documentation using tools like JSDoc to be enough of a discouragement. They leave it up to consumers to know that when they use properties prefixed this way, they are risking breaking changes in future version and generally may encounter unexpected behaviour.
If we instead want to go further and not only discourage but prevent them from accessing our private implementation, we can use the module pattern.
The module pattern take advantages of closures to only expose the properties that the library wants to make available.
The function below is called right away so that it can create a closure over the local bankAccounts
and creditCards
variables and use them throughout the implementation without them ever being exposed.
// CreditCalculator.js
export default (function CreditCalculator() {
const calculateScore = function calculateScore() {
console.log(bankAccounts, creditCards)
}
let bankAccounts = [];
let creditCards = [];
return function(initialBankAccounts, initialCreditCards) {
bankAccounts = initialBankAccounts;
creditCards = initialCreditCards;
this.bankAccounts = bankAccounts;
this.creditCards = creditCards,
this.calculateScore = function publicCalculateScore() {
return calculateScore();
}
}
})();
// client.js
import CreditCalculator from 'CreditCalculator';
const creditCalculator = new CreditCalculator(
['Chase', 'Wells Fargo'],
['Visa', 'MasterCard']
);
creditCalculator.calculateScore(); // 801
A function that is immediately executed like this is referred to in JavaScript as an Immediately-Invoked Function Expression (IIFE).
How can I see which banks it is using in its calculation?
There are different ways a library can choose to let clients access its public data.
Underscore prefixing: We can follow the same pre-fixing practice for access to public vs private data properties.
// CreditCalculator.js
export default (function CreditCalculator() {
const calculateScore = function calculateScore() {
console.log(bankAccounts, creditCards)
}
let bankAccounts = [];
let creditCards = [];
return function(initialBankAccounts, initialCreditCards) {
bankAccounts = initialBankAccounts;
creditCards = initialCreditCards;
this._bankAccounts = bankAccounts;
this._creditCards = creditCards,
this.calculateScore = function publicCalculateScore() {
return calculateScore();
}
}
})();
Get methods: If we want to limit our public API to not directly expose our internal properties, we could instead write a get-prefixed wrapper function.
// CreditCalculator.js
export default (function CreditCalculator() {
const calculateScore = function calculateScore() {
console.log(bankAccounts, creditCards)
}
let bankAccounts = [];
let creditCards = [];
return function(initialBankAccounts, initialCreditCards) {
bankAccounts = initialBankAccounts;
creditCards = initialCreditCards;
this.getBankAccounts = function getBankAccounts() {
return bankAccounts;
};
this.getCreditCards = function getBankAccounts() {
return creditCards;
};
this.calculateScore = function publicCalculateScore() {
return calculateScore();
}
}
})();
Property getters: JS supports customizing how properties can be accessed, modified and removed using the defineProperty API.
// CreditCalculator.js
export default (function CreditCalculator() {
const calculateScore = function calculateScore() {
console.log(bankAccounts, creditCards)
}
let bankAccounts = [];
let creditCards = [];
return function(initialBankAccounts, initialCreditCards) {
bankAccounts = initialBankAccounts;
creditCards = initialCreditCards;
Object.defineProperty(this, 'bankAccounts', {
configurable: false,
enumerable: true,
writable: false,
get: function getBankAccounts() {
return bankAccounts;
}
});
Object.defineProperty(this, 'creditCards', {
value: creditCards,
});
this.calculateScore = function publicCalculateScore() {
return calculateScore();
}
}
})();
In the first example we use the get
attribute of defineProperty
to specify what should happen when a user accesses the bankAccounts
property. In this case we use our function to return the underlying bankAccounts
list. Let’s go through the three additional attributes, configurable
, enumerable
and writable
(CEW):
defineProperty
again and whether you could alter its original CEW definition. For our getter, since we don’t want it to be modified in any of these ways by the client, we will set it to false
.Object.keys
should return that property. Since clients should be able to discover our getter, we will set it to true
.false
as well.Note: All of these properties default to
false
.
In our second example with creditCards
, we don’t define a getter, but instead directly assign a value
to our defined property. This will use the CEW defaults of false, preventing any changes to the property and is a nice short-hand.
How do I update the credit cards that I passed in?
There are two scenarios to consider, a user could attempt to update their credit cards by assignment, by doing something like:
import CreditCalculator from 'CreditCalculator';
const creditCalculator = new CreditCalculator(
['Chase', 'Wells Fargo'],
['Visa', 'MasterCard']
);
creditCalculator.creditCards = ['Visa', 'Amex'];
or they could try to update the data by mutating an existing object:
import CreditCalculator from 'CreditCalculator';
const creditCalculator = new CreditCalculator(
['Chase', 'Wells Fargo'],
['Visa', 'MasterCard']
);
creditCalculator.creditCards.push(['Amex'])
First, let’s look into how we can control data assignment:
Set methods: Similarly to a get
-prefixed function, we could write a simple set wrapper function.
// CreditCalculator.js
export default (function CreditCalculator() {
const calculateScore = function calculateScore() {
console.log(bankAccounts, creditCards)
}
let bankAccounts = [];
let creditCards = [];
return function(initialBankAccounts, initialCreditCards) {
bankAccounts = initialBankAccounts;
creditCards = initialCreditCards;
this.setBankAccounts = function setBankAccounts(newBankAccounts) {
bankAccounts = newBankAccounts;;
};
this.setCreditCards = function setCreditCards(newCreditCards) {
creditCards = newCreditCards;
};
this.calculateScore = function publicCalculateScore() {
return calculateScore();
}
}
})();
Property setters: the defineProperty
API also allows us to define how to assign properties.
// CreditCalculator.js
export default (function CreditCalculator() {
const calculateScore = function calculateScore() {
console.log(bankAccounts, creditCards)
}
let bankAccounts = [];
let creditCards = [];
return function(initialBankAccounts, initialCreditCards) {
bankAccounts = initialBankAccounts;
creditCards = initialCreditCards;
Object.defineProperty(this, 'bankAccounts', {
enumerable: true,
get: function() {
return bankAccounts;
},
set: function(newBankAccounts) {
bankAccounts = newBankAccounts;
}
});
Object.defineProperty(this, 'creditCards', {
enumerable: true,
get: function() {
return creditCards;
},
set: function(newCreditCards) {
creditCards = newCreditCards;
}
});
this.calculateScore = function publicCalculateScore() {
return calculateScore();
}
}
})();
Note: We no longer specify the
writable
attribute, since it would conflict with theset
attribute.
Next let’s look at some options for controlling Data Mutation:
Spread Operator: The spread operator {...}
/[...]
is a useful tool for making data immutable.
// CreditCalculator.js
export default (function CreditCalculator() {
const calculateScore = function calculateScore() {
console.log(bankAccounts, creditCards)
}
let bankAccounts = [];
let creditCards = [];
return function(initialBankAccounts, initialCreditCards) {
bankAccounts = initialBankAccounts;
creditCards = initialCreditCards;
Object.defineProperty(this, 'bankAccounts', {
enumerable: true,
get: function() {
return [...bankAccounts];
},
set: function(newBankAccounts) {
bankAccounts = newBankAccounts;
}
});
Object.defineProperty(this, 'creditCards', {
enumerable: true,
get: function() {
return [...creditCards];
},
set: function(newCreditCards) {
creditCards = newCreditCards;
}
});
this.calculateScore = function publicCalculateScore() {
return calculateScore();
}
}
})();
Cloning: Libraries like Lodash have utilities for cloning objects.
// CreditCalculator.js
export default (function CreditCalculator() {
const calculateScore = function calculateScore() {
console.log(bankAccounts, creditCards)
}
let bankAccounts = Object.freeze([]);
let creditCards = [Object.freeze([]);
return function(initialBankAccounts, initialCreditCards) {
bankAccounts = initialBankAccounts;
creditCards = initialCreditCards;
Object.defineProperty(this, 'bankAccounts', {
enumerable: true,
get: function() {
return _.clone(bankAccounts);
},
set: function(newBankAccounts) {
bankAccounts = Object.freeze(newBankAccounts);
}
});
Object.defineProperty(this, 'creditCards', {
enumerable: true,
get: function() {
return _.clone(creditCards);
},
set: function(newCreditCards) {
creditCards = Object.freeze(newCreditCards);
}
});
this.calculateScore = function publicCalculateScore() {
return calculateScore();
}
}
})();
Freezing: The Object.freeze API can be used to prevent mutation of properties.
// CreditCalculator.js
export default (function CreditCalculator() {
return function(initialBankAccounts, initialCreditCards) {
bankAccounts = Object.freeze([...initialBankAccounts]);
creditCards = Object.freeze([...initialCreditCards]);
Object.defineProperty(this, 'bankAccounts', {
enumerable: true,
get: function() {
return bankAccounts;
},
set: function(newBankAccounts) {
bankAccounts = Object.freeze([...newBankAccounts]);
}
});
Object.defineProperty(this, 'creditCards', {
enumerable: true,
get: function() {
return creditCards;
},
set: function(newCreditCards) {
creditCards = Object.freeze([...newCreditCards]);
}
});
this.calculateScore = function publicCalculateScore() {
return calculateScore();
}
}
})();
Freezing objects prevents adding, removing, writing or re-configuration of a property’s CEW attributes. For an array like in our example, that means that mutative APIs like Array.push
will not update the object.
Note: A gotcha with all of the above approaches is that they do not handle deeply nested objects. There are libraries that support deep cloning and freezing of objects.
At this point we have demonstrated that JavaScript developers have a number of options for designing the different basic components of their libaries:
Component | Pattern |
---|---|
Public API | underscore prefixing, module pattern, property getters |
Data Access | underscore prefixing, get methods, property getters |
Data Assignment | underscore prefixing, set methods, setter properties |
Data Mutation | spread operator, cloning, freezing |
We’ll now see which options some popular libraries choose in their own implementations:
React supplies classes like Component and PureComponent to create its UI. Here is an example from the React docs:
import React from 'react';
class EssayForm extends React.Component {
constructor(props) {
super(props);
this.state = {
value: 'Please write an essay about your favorite DOM element.'
};
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleChange(event) {
this.setState({value: event.target.value});
}
handleSubmit(event) {
alert('An essay was submitted: ' + this.state.value);
event.preventDefault();
}
render() {
return (
<form onSubmit={this.handleSubmit}>
<label>
Essay:
<textarea value={this.state.value} onChange={this.handleChange} />
</label>
<input type="submit" value="Submit" />
</form>
);
}
}
Here they’ve defined a form with an editable text-area that a user can type in. If the user has typed something, it gets stored in the state
data accessor on that component instance.
The React docs make clear that state
is not to be mutated directly:
As a React developer one of the first things you learn is to instead call the setState
API to change your component’s state. This is not, however, enforced by the library and the following lines will execute fine:
this.state.newThing = "this is not a good idea...";
this.state = "yea you shouldn't do this either...";
While this direct assignment will not cause any immediate errors, since it was not done through setState
, React will not know to re-render and since it is not an object, it will blow up at some later execution point.
React has chosen in their API to not limit assignment and mutation in any way for its properties. Instead, it generally relies on convention, its comprehensive documentation and the downstream errors you will get if you were to make a change like this to keep developers in check.
React does call out erroneous assignment at the end of the componentWillMount lifecycle event:
if (typeof instance.componentWillMount === 'function') {
instance.componentWillMount();
}
if (oldState !== instance.state) {
if (__DEV__) {
warningWithoutStack(
false,
'%s.componentWillMount(): Assigning directly to this.state is ' +
"deprecated (except inside a component's " +
'constructor). Use setState instead.',
getComponentName(workInProgress.type) || 'Component',
);
}
classComponentUpdater.enqueueReplaceState(instance, instance.state, null);
}
}
Here in development mode, it calls out that the state
property should not be mutated after running the client’s defined componentWillMount
method. It was a common error for developers learning the library to mutate state in componentWillMount
directly, and React calls out that this should never be done outside of the constructor
, the one place where the state
property can be initialized with direct assignment.
React class components choose to underscore prefix some private properties:
render() {
console.log(Object.keys(this));
/**
0: "props"
1: "context"
2: "refs"
3: "updater"
13: "state"
14: "_reactInternalFiber"
15: "_reactInternalInstance"
*/
}
If you haven’t seen the _reactInternalFiber
property before, that’s the goal. React has pre-fixed it with an underscore to signify that clients should generally avoid touching that property.
To check if they are using the defineProperty
getters approach of hiding some internal properties, we can use the Object.getOwnPropertyNames
API which returns all of the own property keys of an object, even if it was set with enumerable
false.
We can write a quick function to gather all inherited properties by doing this recursively for an object and its prototype:
const getAllProperties = (obj) => {
const props = new Set(Object.getOwnPropertyNames(obj));
const proto = Object.getPrototypeOf(obj);
if (proto) {
getAllProperties(proto).forEach(prop => props.add(prop));
}
return props;
}
Here is what we get when we run it on an instance of a React component:
render() {
console.log(getAllProperties(this));
/**
0: "props"
1: "context"
2: "refs"
3: "updater"
13: "state"
14: "_reactInternalFiber"
15: "_reactInternalInstance"
16: "constructor"
20: "isReactComponent"
21: "setState"
22: "forceUpdate"
23: "isPureReactComponent"
24: "isMounted"
25: "replaceState"
26: "__defineGetter__"
27: "__defineSetter__"
28: "hasOwnProperty"
29: "__lookupGetter__"
30: "__lookupSetter__"
31: "isPrototypeOf"
32: "propertyIsEnumerable"
33: "toString"
34: "valueOf"
35: "__proto__"
36: "toLocaleString"
*/
}
How many of these were intended to be exposed to us? We can check that with the propertyIsEnumerable API:
const getAllPropertiesWithEnumerability = (obj) => {
const props = new Set(Object.getOwnPropertyNames(obj)
.map(prop => `${prop}-${obj.propertyIsEnumerable(prop)}`));
const proto = Object.getPrototypeOf(obj);
if (proto) {
getAllPropertiesWithEnumerability(proto).forEach(prop => props.add(prop));
}
return props;
}
Which now gives us:
render() {
console.log(getAllProperties(this));
/**
0: "props-true"
1: "context-true"
2: "refs-true"
3: "updater-true"
13: "state-true"
14: "_reactInternalFiber-true"
15: "_reactInternalInstance-true"
20: "constructor-true"
21: "isReactComponent-true"
22: "setState-true"
23: "forceUpdate-true"
24: "isPureReactComponent-true"
25: "isMounted-false"
26: "replaceState-false"
27: "__defineGetter__-false"
28: "__defineSetter__-false"
29: "hasOwnProperty-false"
30: "__lookupGetter__-false"
31: "__lookupSetter__-false"
32: "isPrototypeOf-false"
33: "propertyIsEnumerable-false"
34: "toString-false"
35: "valueOf-false"
36: "__proto__-false"
37: "toLocaleString-false"
*/
}
Some of these properties are inherited from base JavaScript objects like isPrototypeOf
or toString
, but if we look at some of React’s own properties like isMounted
or replaceState
, we’ll notice that React made the decision to restrict access to those APIs by setting them to enumerable: false
which you can see here in the source.
As the documentation explains, React did this because those APIs have since been deprecated.
Let’s highlight how React chose to manage its API:
Component | Pattern |
---|---|
Public API | underscore prefixing, module pattern, property getters |
Data Access | underscore prefixing, get methods, property getters |
Data Assignment | underscore prefixing, set methods, setter properties |
Data Mutation | spread operator, cloning, freezing |
Overall, React is pretty liberal with its API exposure, with no controls over data assignment or mutation and limited usage of underscore pre-fixing and enumerability controls.
Moment is a popular library for viewing and working with dates in JavaScript.
Usage of the library might look something like:
import moment from 'moment';
const date = moment();
console.log(date.format('YYYY-DD-MM')); // 2019-30-10
Moment is composed of a wide variety of utility functions for manipulating dates that all live on the moment prototype.
For organization, its source implementation is broken into a folder structure:
src/
lib/
modules/
moment/
from.js
now.js
...
format/
...
parse/
...
This folder structure makes a lot of sense during development, but what what it ends up exposing after it is processed for production is a variation of the module pattern we’ve looked at before. Here is a condensed version of it that highlights the pattern:
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ?
module.exports = factory() :
typeof define === 'function' && define.amd ?
define(factory) :
global.moment = factory()
}(this, (function () { 'use strict';
var hookCallback;
function hooks () {
return hookCallback.apply(null, arguments);
}
// This is done to register the method called with moment()
// without creating circular dependencies.
function setHookCallback (callback) {
hookCallback = callback;
}
// Moment prototype object
function Moment(config) {
copyConfig(this, config);
this._d = new Date(config._d != null ? config._d.getTime() : NaN);
if (!this.isValid()) {
this._d = new Date(NaN);
}
// Prevent infinite loop in case updateOffset creates new moment
// objects.
if (updateInProgress === false) {
updateInProgress = true;
hooks.updateOffset(this);
updateInProgress = false;
}
}
function calendar (key, mom, now) {
var output = this._calendar[key] || this._calendar['sameElse'];
return isFunction(output) ? output.call(mom, now) : output;
}
var proto = Moment.prototype;
proto.add = add;
proto.calendar = calendar;
proto.clone = clone;
proto.diff = diff;
function createFromConfig (config) {
var res = new Moment(checkOverflow(prepareConfig(config)));
if (res._nextDay) {
// Adding is smart enough around DST
res.add(1, 'd');
res._nextDay = undefined;
}
return res;
}
function createLocalOrUTC (input, format, locale, strict, isUTC) {
var c = {};
if (locale === true || locale === false) {
strict = locale;
locale = undefined;
}
if ((isObject(input) && isObjectEmpty(input)) ||
(isArray(input) && input.length === 0)) {
input = undefined;
}
// object construction must be done this way.
// https://github.com/moment/moment/issues/1423
c._isAMomentObject = true;
c._useUTC = c._isUTC = isUTC;
c._l = locale;
c._i = input;
c._f = format;
c._strict = strict;
return createFromConfig(c);
}
function createLocal (input, format, locale, strict) {
return createLocalOrUTC(input, format, locale, strict, false);
}
setHookCallback(createLocal);
hooks.fn = proto;
hooks.min = min;
hooks.max = max;
hooks.now = now;
hooks.utc = createUTC;
hooks.unix = createUnix;
hooks.months = listMonths;
return hooks;
})));
You can check out the full source here.
Let’s take a moment to digest this code. The outer-most function is another immediately-invoked function expression (IIFE). It is immediately called with a passed-in factory function that holds a closure over all of the variables and functions that the library uses.
For an ES6 import statement like import moment from 'moment'
, what is exposed is the return value of the factory as an export: module.exports = factory()
.
This return value is a function which has a number of public utilities on it, like months
:
import moment from 'moment';
console.log(moment.months());
0: "January"
1: "February"
2: "March"
3: "April"
4: "May"
5: "June"
6: "July"
7: "August"
8: "September"
9: "October"
10: "November"
11: "December"
All the helper functions and variables that enable this functionality are not exposed on the object, allowing it to hide its internal implementation.
The function itself can be called to perform the default behaviour of the moment library, which is to instantiate a new Moment
object:
import moment from 'moment';
console.log(moment());
_d: Wed Oct 30 2019 17:20:39 GMT-0400 (Eastern Daylight Time) {}
_isAMomentObject: true
_isUTC: false
_isValid: true
_locale: Locale {_calendar: {…}, …}
_pf: {empty: false, unusedTokens: Array(0)…}
_z: null
As seen above, when it does expose objects like its Moment
function, it heavily relies on the use of underscored properties to make clear its public and private API.
Its internal date is stored under the _d
property, which is only restricted by the underscoring and could be mutated or re-assigned if a client really wanted (but shouldn’t).
Putting it all together, Moment’s API summary looks like this:
Component | Pattern |
---|---|
Public API | underscore prefixing, module pattern, property getters |
Data Access | underscore prefixing, get methods, property getters |
Data Assignment | underscore prefixing, set methods, setter properties |
Data Mutation | spread operator, cloning, freezing |
It is a clear example of a library that uses the module pattern to limit its API and chooses to rely on underscored properties to differentiate its underlying implementation.
JavaScript and its ecosystem doesn’t have just one right way to do something. The goal of this walk-through is to highlight some of the tools and patterns JavaScript developers have available to them for managing the way they build their libraries.
The decisions a library owner makes in exposing their API and controlling access and mutation of its data can have profound impact on the usability, maintainability and ultimately the success of the library within teams, organizations and the larger community.
There are more case studies to come, feel free to reach out on Twitter to suggest any that you would like to see talked about.