TS-36: ECMAScript (JavaScript/TypeScript)

This technical standard covers coding conventions for ECMAScript-based languages – JavaScript and TypeScript.

Java is to JavaScript
as Car is to Carpet.

Contents

Language fundamentals

Files

Files containing JavaScript executables MUST have the .js file extension. File names MUST be all lower case and MAY include dashes (-) but not underscores (_); there MUST NOT be any additional punctuation. All JavaScript files MUST be encoded in UTF-8.

The line terminator sequence and the ASCII horizontal space character (0x20) MUST be the only whitespace characters used within a JavaScript source file. Other whitespace characters in string literals MUST be escaped. Tab characters MUST NOT be used for indentation.

For characters that have special escape sequences, the escape sequence MUST be used rather than a numeric escape (e.g. \x0a, \u000a, or \u{a}). Legacy octal escapes MUST NOT be used. The special escape sequences are: \', \", \\, \b, \f, \n, \r, \t, \v.

Printable non-ASCII characters SHOULD be printed as their actual Unicode character:

const units = 'μs'

It SHOULD NOT be necessary to use an equivalent hex or Unicode escape for such characters. If it is necessary, the reason MUST be documented in an adjacent comment, and the literal character printed in an end-of-line comment:

const units = '\u03bcs' // 'μs'

Use of non-printable characters MUST be accompanied by an inline comment:

return '\ufeff' + content // Prepend a byte order mark.

Type system

JavaScript is generally used as a duck typed language. There is no need to tell a function what type of parameters to expect, so values are often passed by type – this is duck typing. If the object looks like a duck and quacks like a duck, it must be a duck. But it might not actually be a duck; the passed object might just have an interface that is compatible with the Duck type.

JavaScript is weakly typed. It does not support strong typing natively, but the language can be extended with static type analysis at compile time. We use TypeScript for this purpose; see TypeScript.

There are sometimes advantages to duck typing, and sometimes it may even be preferable to strong typing. The explicit nature of strong typing is that it can make function signatures too rigid. Dynamic methods that accept and return different types based on context are impossible to implement with strong typing. The most appropriate type system SHOULD be used depending on the context in which the API is expected to be used.

The main advantage of strong typing is that the compiler can often provide build-time error checking, before the program is even run.

Primitive values

ECMAScript has two numeric representations: the IEEE 754 floating-point Number type, and BigInt for arbitrary-precision integers.

Numbers

JavaScript’s number type uses IEEE 754 binary floating point, which cannot exactly represent most decimal fractions. The classic illustration:

0.1 + 0.2 !== 0.3 // true

Binary floating point is ill-suited to monetary and other business calculations. To avoid these errors, decimal values SHOULD be scaled to whole numbers – for example, monetary values represented as 2550 cents instead of 25.50 dollars. If precision to two decimal places is needed, scale by 100; to four, scale by 10,000. Integer addition, subtraction, and multiplication then produce exact results, up to the limits of the number type.

1.0 + 2.0 === 3.0 // true
0.1 + 0.2 === 0.3 // false

Where scaling is impractical, custom value types or dedicated libraries – such as Decimal.js or bignumber.js – provide exact decimal arithmetic:

const n1 = new Decimal('1234')
const n2 = new Decimal('8765')
n1.add(n2) // new Decimal('9999')

BigInt

BigInt is the appropriate type for integers that exceed Number.MAX_SAFE_INTEGER. It cannot be perfectly polyfilled, so BigInt MUST be used only in programs that target runtime environments that support it natively.

Strings

A string is a sequence of zero or more characters. String primitives are defined with single or double quotes; prefer single quotes, switching to double quotes only where that avoids ugly escaping:

let hello = 'Hello, World'
let example = "That's what I said"

Template literals (backticks) allow interpolation and multi-line strings:

const hello = `Hello, ${name}`

To test whether a string contains a substring, use String.prototype.includes, which returns a boolean – not indexOf, whose purpose is to return an index:

if (philosophers.includes('Joshua')) {
  // ...
}

Types and coercion

JavaScript has seven types: six primitives (undefined, null, boolean, number, string, symbol) and object. Most things in JavaScript are objects; even functions are special objects. Booleans, numbers, and strings can also be created as wrapper objects (new Boolean, new Number, new String), but the wrappers behave differently from the primitives and are a common source of bugs.

Primitives and wrapper objects

Always define boolean, number, and string values as primitives, never as wrapper objects. A Boolean object is truthy even when its internal value is false:

let test = new Boolean(false)
if (test) {
  // The condition evaluates true – a bug.
}

If you need the primitive value of a wrapper, use valueOf(). To coerce another value to a primitive, use Boolean(), Number(), or String() as ordinary functions (without new):

String(val)
Number(val)
Boolean(val)

Shorthand coercions ('' + val, +val, !!val) work but are less clear.

Truthy and falsy values

A value is truthy if it is, or coerces to, true; falsy if it is, or coerces to, false. There are only six falsy values:

  • false
  • null
  • undefined
  • NaN
  • 0
  • '' (empty string)

Everything else (including any object, any non-empty string, any non-zero number, and any array – even an empty one) is truthy. Truthiness is not the same as loose equality (== true); prefer explicit checks where intent is unclear.

typeof and instanceof

typeof returns the type of a value as a string, and is safe to use on undeclared variables. instanceof tests whether an object is an instance of a constructor (checking the prototype chain):

typeof 'hello' // 'string'
typeof undefined // 'undefined'
[] instanceof Array // true

typeof null returns 'object' (a long-standing bug), and typeof cannot distinguish subtypes of objects – use instanceof or Array.isArray() for that.

Operators

Equality

Always use === and !== to compare values. The == and != operators use implicit type conversion, whose behavior is unintuitive:

'' == '0'   // false
'0' == ''   // true
false == '0' // true
'\t\t\n' == 0 // true

Nullish coalescing and optional chaining

The nullish coalescing operator ?? returns its right operand when the left is null or undefined (and only those – unlike ||, which checks all falsy values):

const score = input ?? 0

The optional chaining operator ?. short-circuits to undefined when the left operand is null or undefined, avoiding a TypeError:

const name = movie?.name

Spread

The spread operator (…​) expands an iterable into individual elements (in array literals and function calls) or copies an object’s own enumerable properties (in object literals):

const added = [...numbers, 4]
const updated = { ...person, name: 'Peter' }

Other operators

Use in to test for a property name (or array index), and delete to remove an own property. Avoid bitwise operators except for genuine bit-level work: they coerce operands to 32-bit integers and obscure intent when misused.

Statements and expressions

JavaScript distinguishes statements (executable units, like sentences) from expressions (units that evaluate to a value). An expression can be written wherever a value is expected; a statement cannot be written where an expression is expected.

Write one statement per line. Each statement MUST end with a semicolon, with the exception of for, function, if, switch, try, and while. Function expressions and do…​while statements MUST end with a semicolon:

const doSomething = function () {
  // ...
};

Because of automatic semicolon insertion, the expression in a return statement MUST be on the same line as the return keyword, and opening curly braces MUST be on the same line as whatever they open:

// Correct – returns the object.
return {
  result: false
}

// Wrong – returns undefined (semicolon inserted after `return`).
return
{
  result: false
}

Avoid unreachable code: return, break, continue, and throw SHOULD be followed by }, case, or default. Avoid blockless statements and bare expression statements (foo;), both of which are bug-prone.

Prefer literal expressions over the new operator: use [] instead of new Array(), {} instead of new Object(), and primitive types instead of new String(), new Number(), or new Boolean().

Control structures

Control statements SHOULD have one space between the control keyword and the opening parenthesis, to distinguish them from function calls. Curly braces SHOULD always be used, even where optional, and the opening brace MUST be on the keyword line.

else and else if clauses SHOULD begin on a new line, rather than } else { on one line. This reduces the chance of introducing a syntax error when commenting out blocks.

Long conditions SHOULD be broken into nested conditionals rather than chained with many binary operators on one line:

// Better than a single long `if (a && b && c && d)`.
if (gender === 'male') {
  if (income > 30000) {
    if (age > 18 && age < 35) {
      // ...
    }
  }
}

switch

Prefer if/else chains over switch where they read as well. A switch case MUST NOT intentionally fall through to the next case – every case SHOULD end with break (or return/throw), so that accidental fall-through is easy to spot. Where a switch would dispatch on a value to a function, prefer a map or dispatch table (a data structure), which is easier to extend:

const commandTable = {
  north: moveNorth,
  east: moveEast,
  west: moveWest,
  south: moveSouth
}

commandTable[command]()

Loops

Use for, while, and do…​while for counted loops; for…​of for iterables (arrays, strings, etc.); and for…​in only for object property enumeration. break exits the innermost loop (use labels to exit an outer loop); continue jumps to the next iteration. Optimize loops by avoiding repeated property access or function calls in the condition:

for (let i = 0, len = items.length; i < len; i++) {
  // ...
}

for…​in iterates over inherited properties as well as own properties, which is rarely what you want; guard it with Object.hasOwn(), or use Object.keys()/Object.entries() for own properties only:

for (const prop in obj) {
  if (Object.hasOwn(obj, prop)) {
    // ...
  }
}

Arrays

Arrays are list-like objects with a length property and integer keys. Define them with the literal [] syntax, never the Array constructor (whose single-number-argument behavior is a gotcha):

const arr = [1, 2, 3]

To test whether a value is in an array, use Array.prototype.includes, not indexOf(…​) !== -1. To test whether any or all elements satisfy a condition, use some or every – both short-circuit. There is no break in forEach; use some (or a for…​of loop) when you need to stop early:

[1, 2, 3].some((el) => {
  return el === 2 // stops after the first match
})

To remove or transform elements, prefer the non-mutating filter and map methods over in-place mutation with splice. Convert array-like objects (NodeList, arguments) to true arrays with Array.from() or the spread operator, not the Array.prototype.slice hack.

Strict mode

Strict mode (ES5) is a restricted variant of JavaScript that throws errors for dodgy features and bad practices – it prohibits most uses of eval, assignment to undeclared variables, duplicate object properties, and overriding the arguments object. With the introduction of ES modules, which are always strict, strict mode has effectively become the default in modern JavaScript.

In classic (non-module) scripts, strict mode is enabled with the 'use strict' directive at the top of a file or function. Avoid enabling it in the global scope of a classic script (it would apply to concatenated code that may not be strict-compliant); scope it to an IIFE instead. ES modules and class bodies are always strict, so no directive is needed there.

'use strict'

function strict() {
  // ...
}

Regular expressions

Regular expressions are powerful but processor-intensive and hard to read. Where a more expressive alternative achieves the same result, prefer it. Complex patterns MUST be accompanied by comments explaining their meaning.

Syntax and style

Low-level code style concerns SHOULD be enforced through linting tools such as ESLint.

Code style

Low-level style (indentation, spacing, quotes) SHOULD be enforced automatically: a formatter such as Prettier handles visual formatting, and a linter such as ESLint handles correctness and best-practice rules, both running in continuous integration to take subjectivity out of code review. The conventions below are the defaults where a project does not specify otherwise.

Indentation. Use two spaces per level; prefer spaces over tabs. The only wrong indentation is inconsistent indentation – when joining a project, adopt its prevailing conventions.

Quotes. Prefer single quotes (') for string literals, switching to double quotes where that avoids escaping (see Strings). Single quotes are particularly helpful for embedding HTML attribute strings.

Parentheses. Use parentheses only where required by the syntax and semantics, or where they improve readability. Never parenthesize unary operators (delete, typeof, void) or follow keywords such as return, throw, case, in, or new.

Clarity over brevity. Write for humans first – assume maintainers may be less familiar with the code than you, and prefer consistency and clarity over terse one-liners. Include arrow-function parentheses and braces consistently rather than only where the syntax forces them:

const doubled = arr.map((el) => { return el * 2 })

Naming. Prefer explicit names over implicit ones: sleepForSeconds(t) is clearer than sleep(t), because the unit the parameter expects is evident from the function name alone. (See Naming conventions.)

Naming conventions

Classes

Use UpperCamelCase for class names.

Protocols that describe what something SHOULD read as nouns, e.g. Collection. Protocols that describe a capability SHOULD be named using suffixes like "able", "ible" or "ing", e.g. Equitable, ProgressReporting.

Functions and methods

Use lowerCamelCase for functions and methods.

Name functions and methods according to their side effects. Methods without side effects SHOULD read as noun phrases: obj.distance({ from: x, to: y }). Those with side effects SHOULD read as imperative verb phrases: print(), sort(), append().

Factory methods SHOULD be prefixed with the verb "make": makeModel(), makeIterator().

Mutating and non-mutating methods SHOULD be distinguishable from their naming. When the operation is naturally described by a verb, use the verb’s imperative for the mutating method (sort(), append()) and apply the "ed" or "ing" suffix to name its non-mutating equivalent that returns a new, modified instance (sorted(), appending()).

When the operation is naturally described by a noun, use the noun for the non-mutating method (union(), successor()) and apply the "clone" prefix to its mutating counterpart (cloneUnion(), cloneSuccessor()).

In keeping with the principles of functional programming, prefer to implement non-mutating methods and functions wherever practical. Most objects SHOULD be immutable after construction. The exception to this rule is objects that are built over time, such as the object that represents the HTTP message to be emitted by a web application.

Uses of boolean methods and properties SHOULD read as assertions about the receiver when the use is non-mutating (isEmpty(), intersects()).

Variables and properties

Use lower_case for properties and variables.

Name variables, parameters and their associated types according to their roles, rather than to their type constraints.

Choose parameter names to serve as documentation. Even though parameter names do not appear at a method’s point of use, they play an important explanatory role.

Take advantage of default properties when it simplifies common uses. Any parameter with a single commonly-used value is a candidate for a default.

Prefer to locate parameters with defaults towards the end of the parameter list. Parameters without defaults are usually more essential to the semantics of a method, and provide a stable initial pattern of use where methods are invoked.

Variable declarations

Modern JavaScript has three declaration keywords: const, let, and var.

  • Prefer const for any binding that is not reassigned. const is the strongest signal of intent: the binding will not change.
  • Use let where reassignment is genuinely needed – loop counters, value swaps, accumulators. A let binding MAY be reassigned but is still block-scoped.
  • Avoid var entirely. It is function-scoped (not block-scoped), hoisted, and permits redeclaration, all of which are sources of bugs. Any var can be replaced with const or let, and a remaining var is a code smell.

const and let are block-scoped and are not hoisted in the way var is: accessing them before their declaration throws a ReferenceError (the temporal deal zone).

const makes the binding immutable, not the value. For primitives this means the value cannot change; for objects, the binding cannot be reassigned but the object’s properties can still be mutated. To make an object truly immutable, combine const with Object.freeze() (see Objects and classes).

Declare each variable on its own line and initialize it at the point of declaration where practical. An identifier SHOULD represent a single concept for the whole of its lifetime – do not reuse a variable for a different purpose.

Scope and this

JavaScript has three scopes: block scope (the default for const and let), function scope (the unit for var), and module scope (the top level of an ES module is its own scope, not the global scope). Variables assigned without a declaration keyword become properties of the global object.

The global object differs by runtime (window in browsers, self in workers, global in Node). ES2020’s globalThis is the standard alias for the global object across all runtimes.

Global variables SHOULD be avoided. Encapsulate state in modules, functions, and custom objects instead. Globals can be changed by any code in the same thread, inviting naming collisions and unpredictable behavior. All variables used inside a function MUST be either passed as arguments or declared within the function – a function SHOULD NOT read or mutate variables from an outer (non-module) scope.

Where a global is genuinely unavoidable – typically when integrating with a host page that is not an ES module – confine all public identifiers to a single namespace object on the global object rather than scattering many globals:

globalThis.App = globalThis.App ?? {}

App.formatError = function (err) {
  return `${err.project_id} error: ${err.message}`
}

This keeps the global surface to one name and groups related functionality. It is a fallback for legacy integration only, not a substitute for ES modules.

this is a binding created at call time that refers to the current execution context. It is not necessarily the object in which a function was defined – the same function can be called against different objects, and this changes accordingly. Arrow functions do not bind their own this (they inherit it lexically), so call, apply, and bind cannot rebind them. For details of this binding and call/apply/bind, see Functions.

Comments

Comments are annotations in source code that are ignored by parsers but let developers leave notes for each other. Start by making programs as self-documenting as possible – meaningful names, clear structure – but well-written code documents only what a program does, not why the developer chose a given approach. Comments transfer that remaining knowledge to other developers.

Syntax

There are two ways to write comments in JavaScript:

/* A multiline comment is written like this. */

// A one-line or end-of-line comment

The // inline syntax MUST be used for all unstructured inline annotations. It is fine to use this notation for longer, multi-line comments, but all comments SHOULD be on their own lines; authors SHOULD NOT write comments on the end of lines.

// Enable or disable spelling errors on compound words like
// 'errormessage' and 'builddir'.

The multi-line comment syntax is used for machine-readable API documentation (docblocks), covered below.

Language

All inline source code comments MUST be written in American English, using proper sentences and grammar throughout.

Inline API documentation

The block-level comment syntax is extended to demarcate comments that are both machine-parsable and human-readable, for the purpose of generating API documentation. The convention is to add an extra asterisk immediately after the opening /*:

/** A single-line API comment. */
/**
 * A multi-line API comment block.
 */

These comment blocks – called docblocks – document the code that immediately follows them:

/**
 * Error type representing failed assertions.
 *
 * @example
 * throw new AssertionError('Expected a number, received a string')
 */
class AssertionError extends ValidationError {}

Writing API documentation inline in source code keeps it more likely to be maintained alongside the code, and provides an at-a-glance reference in the editor. Using a machine-readable format brings further benefits: tools can generate API documentation automatically, and IDEs can pipe the schema into IntelliSense for code completion and static analysis.

In the JavaScript ecosystem, variations on the syntax supported by JSDoc are widely used to embed structured API information, including type information, into source code comments. These tools have now been largely supplanted by TypeScript: the TypeScript compiler supports an extended subset of JSDoc, and the emerging TSDoc specification further extends TypeScript-flavoured JSDoc for a wider range of tools.

Using TypeScript-compatible JSDoc/TSDoc notation is an easy and effective way to maintain API documentation for JavaScript modules, and to bring much of TypeScript’s type system to plain .js files – including real-time type checking in editors like VS Code. For library development, the TypeScript compiler can generate *.d.ts type declaration files from comments embedded in plain .js files, so typed versions of JavaScript libraries can be distributed.

It is our policy to fully cover the source code of all our JavaScript libraries and applications with inline structured API information, using a minimal subset of JSDoc/TSDoc that is easy to write and maintain and that improves the developer experience by conveying information not readily understood from the source code alone.

Internally, VS Code’s IntelliSense uses the TypeScript compiler (tsc) – for both TypeScript and plain JavaScript files – to infer type information and to generate API documentation from code. To build a complete profile of a program’s internal API, tsc parses information encoded in JSDoc-compatible comments.

Docblock style

Authors MUST use the multi-line docblock notation, not the single-line notation, so that docblocks are easily distinguished from normal comments:

/**
 * @type {string}
 */
let s

Except for file-level docblocks (which start on the very first line of a file), every docblock MUST have a single empty line before it, and the immediate next line after a docblock MUST be the start of the code it documents. These rules differ from // comments, which MAY be padded with empty lines above and below.

Descriptions are OPTIONAL. Where included, they MUST be written on the first line of the docblock, with one empty line between the description and the docblock’s tags:

/**
 * A description of what the return value of this promise represents.
 *
 * @type {Promise<string>}
 */
const promisedString

Descriptions MAY span multiple lines and paragraphs but SHOULD be kept concise and focused on API information. Proper sentences in American English are REQUIRED.

Markdown MAY be used in docblock descriptions and in end-of-line descriptions for @param, @returns, and @throws tags: backticks for inline code, single asterisks for bold, underlines for italics, and [Link text](http://…​;) hyperlink syntax. Authors MUST NOT use inline HTML or other rich-text markup.

/**
 * Returns `true` for anything that can be coerced into a valid number.
 *
 * **Important:** This note will be rendered in VS Code's IntelliSense tooltips.
 *
 * @param {*} test Test variable.
 * @returns {boolean} Returns `true` if numerical, else `false`.
 */
function isNumeric (test) {
  // ...
}

While code and standard comments SHOULD be less than 80 characters long, docblocks MAY be longer – up to 120 characters or more – to avoid hard-wrapping long @tag lines. Where a tag needs a longer description, hard-wrapped lines are indented by two spaces:

/**
 * @param {string} author The author of the book, presumably some
 *   person who writes well and does so for a living!
 */

Authors MUST NOT align the types, names, and descriptions of a group of tags. Alignment makes line lengths longer and forces every line to change when any one is updated, making source-control merges noisier.

/**
 * @typedef {Object} SpecialType Creates a new type named 'SpecialType'.
 * @prop {string} prop1 A string property of SpecialType.
 * @prop {number} prop2 A number property of SpecialType.
 * @prop {number} [prop3=42] An optional number property of SpecialType with default.
 */

Tags

Tags, prefixed with @, provide machine-readable information. We use a small, useful subset of the tags specified by JSDoc and TSDoc:

@alpha, @author, @beta, @callback, @copyright, @deprecated, @example, @experimental, @extends, @license, @link, @param, @prop, @see, @returns, @template, @this, @throws, @type, @typedef.

All tags are block-level except @link, which is inline.

Types

Type information is provided via the @type, @param, and @returns tags, with help from @typedef and @callback. Types are written in curly braces – e.g. {string}, {number}, {boolean}, {null}, {42} (the literal number 42), {'close' | 'open'} (one of two literal strings), {string[]} (an array of strings), {…​string} (a variable number of string arguments), or {MyElement} (a custom type).

JavaScript’s primitive types are written in full lower case: boolean, number, string, undefined, null. For functions that return undefined, declare the return type as void – a TypeScript-specific type for functions with no return statement.

string is a different type to String, but the distinction is often immaterial because engines implicitly coerce primitives to their object wrappers. Prefer to declare types as primitives (string, number, boolean) rather than object wrappers (String, Number, Boolean). Any built-in object (Object, RegExp, Function, Array, Date, Error, TypeError, etc.) is supported, as are host types such as HTMLElement where the runtime provides a DOM.

/**
 * @type {HTMLElement}
 */
const el = document.querySelector(selector)
el.dataset.myData = ''
Union and nullable types

Variables that may reference more than one type MUST declare the full range of possible types using union syntax, which is also used for nullable types:

/**
 * @type {string | boolean}
 */

/**
 * @type {number | null}
 */

Place one space either side of the pipe |, for consistency with TypeScript notation.

Objects

Objects SHOULD be fully typed. For simple structures, prefer TypeScript syntax:

/**
 * @type {{ a: string, b: number }}
 */

/**
 * @type {{ a: string, b?: number }}
 */

Optional properties MAY use the ? notation (equivalent to b: number | undefined). Map-like objects use index signatures:

/**
 * @type {{ [x: string]: number }}
 */

For more complex structures, use @typedef (see Type definitions).

Arrays
/**
 * @type {string[]}
 */

/**
 * @type {(string | number)[]}
 */

The Array<string> syntax is also supported.

Promises

For promises whose resolved value is insignificant:

/**
 * @type {Promise}
 */

Where the resolved value is significant, declare it:

/**
 * @type {Promise<string>}
 * @type {Promise<(string | number)>}
 */

Rejected values (which can be expected to be some Error type) are not documented.

Function types
/**
 * @type {(s: string, b: boolean) => number}
 */
const fn = (s, b) => {
  // ...
}
Unknown and any types

For an unknown structure from an external, untyped source, declare Object and document why the structure cannot be typed:

/**
 * An arbitrary data structure provided by an external web service.
 *
 * @type {Object}
 */

For functions with complex signatures you cannot document, use Function. To declare the equivalent of TypeScript’s any, use JSDoc’s * type:

/**
 * This variable is a return value from an external component,
 * and could be of any type.
 *
 * @type {*}
 */

Non-specific any types SHOULD be avoided; wherever you disable the type checker, you MUST document your reasons. Do not use the ? ("unknown") type – use * ("any") instead.

Type definitions

@typedef documents complex types – the equivalent of a TypeScript interface:

interface Address {
  street: string;
  city: string;
  zip?: number;
}

The equivalent inline, using TypeScript syntax (the identifier Address is just a reference and need not match anything in the source):

/**
 * @typedef {{ street: string, city: string, zip?: number }} Address
 */

Or, to comment each property, JSDoc syntax:

/**
 * @typedef {Object} Address
 * @prop {string} street
 * @prop {number} city
 * @prop {number} [zip]
 */

@typedef blocks stand on their own and need not document the code immediately below them; once declared, the type can be referenced from any subsequent @type, @param, or @returns tag:

/**
 * @typedef Token
 * @property {boolean} valid True if the token is valid.
 * @property {string} id The user id bound to the token.
 */

/**
 * Consume a token.
 * @param {string} token Token string.
 * @returns {Promise<Token>} A promise that resolves to the token.
 */
const consumeToken = (string) => {
  // ...
}

@typedef can also bring definition to functions with complex signatures (see Documenting functions).

Imported type definitions

The TypeScript compiler can import TypeScript type declaration files (.d.ts) directly into JavaScript via inline JSDoc, using the import() syntax on @type, @param, or @returns:

// types.d.ts
export type Pet = {
  name: string
}
/**
 * @param { import('./types').Pet } pet
 */
function walk (pet) {
  console.log(`Walking ${pet.name}...`)
}

A cleaner option is to import() a type into @typedef, alias it, and reference the alias:

/**
 * @typedef { import('./types').Pet } Pet
 */

/**
 * @type {Pet}
 */
let myPet

Combine import() with typeof to discover the type of a value from a JavaScript module:

/**
 * @type {typeof import('./accounts').userAccount }
 */
const x = require('./accounts').userAccount

Variables

The data types held by variables are declared with @type:

/**
 * @type {string}
 */
let s

Anything exported from a module MUST have its range of possible types declared with @type. It is RECOMMENDED to declare types for internal variables too, to improve type-checking accuracy. Descriptions SHOULD be included for variables whose purpose is not obvious:

/**
 * Regular expression used to contain individual keystrokes within the
 * input control. Only numbers and ASCII-range letters are allowed.
 *
 * @type {RegExp}
 */
const allowedCharactersPattern = /[A-Z0-9]/gi
Type casts

TypeScript borrows cast syntax from Google Closure: add a @type tag before any parenthesized expression. This is the only place single-line JSDoc notation is used:

/**
 * @type {number | string}
 */
let numberOrBool = Math.random() < 0.5 ? false : 0
let assumedNumber = /** @type {number} */ (numberOrBool)

Documenting functions

Functions SHOULD be fully typed, declaring all argument and return types. For concise APIs, TypeScript notation MAY be used:

/**
 * @type {(s: string, b?: boolean) => number}
 */
const fn = (s, b) => {
  // ...
}

For complex signatures, or where individual parameters and return values should be commented, JSDoc notation is better. A function’s signature is described with @param, @returns, and @throws tags, listed in that order. @param and @returns tags are clustered together, one per line, with no blank lines between them.

/**
 * Delete the contents of a directory.
 *
 * @param {string} dir Path to the target directory.
 * @returns {Promise<string>} Path to the cleaned directory.
 *
 * @throws `TypeError` if missing or invalid parameters.
 */

Both styles work for function declarations and function expressions.

Parameters

@param uses the same syntax as @type but adds a parameter name. Optional parameters are enclosed in square brackets. All parameters SHOULD have comments unless their purpose is obvious:

/**
 * @param {string} p1 A string parameter.
 * @param {string} [p2] An optional string parameter.
 */

Default values:

/**
 * @param {string} p1
 * @param {string} [p2='default']
 */

Rest arguments:

/**
 * @param {...string} other
 */

TypeScript or JSDoc notation MAY describe object structures. JSDoc notation is particularly good for options objects – nested properties are prefixed with the parameter name:

/**
 * @param {Object} options
 * @param {string} options.prop1
 * @param {number} [options.prop2]
 * @param {number} [options.prop3=42]
 */

The same notation documents destructured parameters:

/**
 * @param {Object} options
 * @param {string} options.prop1
 * @param {number} options.prop2
 */
const fn = ({ prop1, prop2 }) => {
  // ...
}
Return types

Function docblocks MUST have a single @returns tag declaring all possible return types, REQUIRED even if the function returns undefined or is async. Every @returns tag MUST have a comment (shown in VS Code tooltips). Type checkers can infer promises from async, but you MUST declare the return type for functions that return new Promise() rather than use async, and SHOULD declare it where the resolved value is significant. Even a promise with no meaningful value should be declared Promise<undefined>.

/**
 * @returns {boolean | null} Returns `true`, `false`, or `null`.
 */

/**
 * @returns {Promise<string>} Returns a promise that resolves to a string.
 */
async function ps () {
  // ...
}

For functions with no return statement:

/**
 * @returns {void} No return value.
 */

The void type is a TypeScript-specific subtype of undefined for functions that return nothing.

Thrown values

A block of @throws tags MAY follow the @param/@returns block, separated by a single empty line. Use one @throws tag per error type (do not use the union operator). For VS Code tooltip compatibility, the thrown type MUST NOT be wrapped in {}; instead, each tag has a short comment beginning with the type name in backticks:

/**
 * @throws `TypeError` if any argument is of an unexpected type.
 */

/**
 * @throws `DivideByZero` if argument `x` is not a non-zero value.
 */

@throws is informational only – it does not restrict other types from being thrown.

Callbacks

@typedef MAY declare function signatures reusable across multiple Function instances:

/**
 * @typedef {(path?: string) => boolean} onFileOperation
 */

/**
 * @type {onFileOperation}
 */
const ok = (s) => !(s.length % 2)

The @callback tag serves the same purpose, originating from JSDoc and giving room to comment individual parameters, return types, and thrown types. As with @typedef, @callback declarations can be referenced from @type, @param, and @returns:

/**
 * @callback onFileOperation
 *
 * @param {string} [path] Path to a source file.
 * @returns {boolean} Whether the file was modified.
 */

/**
 * @type {onFileOperation}
 */
function onChange (path) {
  // ...
}
@this

The TypeScript compiler infers the type of this from call context. Where context is not provided, specify it with @this:

/**
 * @this {HTMLElement}
 * @param {*} e An `Event` instance.
 */
function callback (event) {
  // ...
}

Classes

All classes SHOULD be described with a docblock:

/**
 * Description of this class.
 */
class ExampleClass {
  // ...
}

Construction parameters, if any, MUST be declared in a docblock on the constructor:

/**
 * @param {number} data
 */
constructor (data) {
  // ...
}

Note that even the TypeScript type checker allows classes to be invoked without new; the result is treated as any.

Class fields need type declarations only where the type cannot be inferred from an assigned value. Where a field is declared and its value set elsewhere, declare the expected type:

/**
 * @type {number}
 */
size

Class method signatures use the same JSDoc syntax as standalone functions (see Documenting functions).

Templates

@template declares generic types that "flow through" functions or classes:

/**
 * @template T
 * @param {T} x
 * @returns {T}
 */
function id (x) {
  return x
}

id('str')
id(123)
id({})

This is a better option than any for type checking, but is not expressive for the human reader – add a comment explaining that T is a container for the passed-in type. The letter T is conventional; any identifier works, and a single upper-case letter is conventional. Multiple templates MAY be declared comma-separated via one @template tag, or one per tag. @template can be combined with @extends to pass argument types down from a generic base class:

/**
 * @template T
 * @extends {Set<T>}
 */
class SortableSet extends Set {
  // ...
}

File-level docblocks

All JavaScript/TypeScript files MUST have a single file-level docblock, beginning on line 1:

/**
 * A short description of the file contents.
 *
 * @see       {@link https://example.com/docs}
 * @author    Your Name
 * @copyright Your Org
 * @license   MIT
 */

@file and @fileOverview are not used; the leading prose serves as the module description. @author is required only if authorship differs from the copyright owner. Contact details such as email addresses MUST NOT be embedded in any docblock tags. Copyright declarations MAY be dated and MAY list multiple holders comma-separated; it is RECOMMENDED to omit the date so notices need not be updated yearly. These tags MAY be repeated in other docblocks within a file wherever the copyright or license differs from the file-level default, and where used MUST be positioned after all other tags.

Release stages

The @experimental, @alpha, @beta, and @deprecated tags MAY declare the release stage of a component. Where used, they SHOULD be positioned near the top of a docblock, preferably immediately after the description:

/**
 * Returns the average of two numbers.
 *
 * @beta
 *
 * @param x The first input number.
 * @param y The second input number.
 *
 * @returns The mean of `x` and `y`.
 */

For experimental and deprecated components, include a comment to draw attention:

/**
 * @deprecated This export will be removed in the next major release.
 */

@since and @version MUST NOT be used; versioning is defined in package.json and API changes recorded in changelogs.

Examples

Code samples are included with @example (ignored by the type checker, but shown in VS Code tooltips). There MAY be multiple @example blocks, each preceded by a single empty line; the next line MUST start the code. Example code MUST be correct and executable in isolation without modification.

/**
 * Adds two numbers together.
 *
 * @param {number} x
 * @param {number} y
 *
 * @returns {number}
 *
 * @example
 * // Prints `2`.
 * console.log(add(1, 1))
 *
 * @example
 * // Prints `0`.
 * console.log(add(1, -1))
 */
function add (x, y) {
  // ...
}

References

Use @see with @link to reference documentation and related resources. @see specifies a "see also" item; @link is an inline tag that creates hyperlinks, usable within block-level tags like @see:

/**
 * @see {@link https://example.com/docs}
 */

Multiple @see blocks are allowed, and links may be given a human-friendly title:

/**
 * @see {@link https://example.com/ Project website}
 */

Documentation references SHOULD be included in most file-level docblocks and wherever relevant external documentation exists.

Other docblock syntax

The TypeScript compiler supports a handful more tags (@override, @implements, @public, @private, @protected), but these have not been particularly useful for JavaScript. JSDoc and TSDoc define further syntax, but much of JSDoc is legacy for pre-ES6 code (e.g. @constructor), and TSDoc is an emerging superset aimed at a wider range of tools.

You MAY use any additional tags supported by the TypeScript compiler, JSDoc, or TSDoc, but additional markup SHOULD be used sparingly. Docblocks work best with just enough information for type checking and correct API usage. Used wisely, docblocks reduce cognitive load; misused, they do the opposite. Above all, docblocks MUST be readable and understandable even to developers unfamiliar with JSDoc/TSDoc.

JSDoc/TSDoc notation is a stepping stone from JavaScript to TypeScript. If an application grows complex enough to benefit from tags like @enum, consider incrementally refactoring to full TypeScript:

/**
 * @enum {boolean}
 */
const ModalOpenState = {
  Closed: false,
  Open: true,
}

TypeScript docblocks

Docblocks SHOULD be included in TypeScript files; the only difference is that types already declared in the code are not duplicated:

/**
 * Returns the average of two numbers.
 *
 * @param x The first number.
 * @param y The second number.
 * @returns The mean of `x` and `y`.
 */
function getAverage (x: number, y: number): number {
  return (x + y) / 2.0
}

To document TypeScript interfaces, JSDoc comments may be placed preceding the interface definition or preceding individual properties. Any combination is parsed by the type checker and piped into VS Code’s IntelliSense:

/**
 * Represents an item in a customer's basket.
 */
export interface BasketItemInterface {
  /** A unique identifier, expected to be in the UUID format. */
  id: string

  /** Stock-keeping unit, unique to each type of product. */
  sku: string

  /** The name of the product as printed in the UI and on receipts. */
  name: string

  /** The item's retail price. */
  price: PriceType
}

Interface-level docblocks are OPTIONAL but SHOULD be included where the purpose is not clear from the interface name. Property-level docblocks are OPTIONAL but SHOULD be included for properties whose purpose is not clear from their identifiers. If reverse-engineering usage in code is needed to understand what an interface or property is for, authors SHOULD add a comment so this work becomes unnecessary.

VS Code configuration

To benefit from inline type checking of JavaScript files in VS Code, JavaScript validation must be enabled (it is by default), and the type checker must parse docblocks in JS files. This can be enabled per file with a // @ts-check comment on the first line, or globally with the js/ts.implicitProjectConfig.checkJs setting:

{
  "js/ts.implicitProjectConfig.checkJs": true
}

The type checker parses compatible docblocks in each file for type information that cannot be inferred from the code. If enabled globally, it can be disabled per file with // @ts-nocheck; @ts-ignore suppresses type errors on the next line.

For a consistent experience across environments, it is strongly RECOMMENDED to both enable js/ts.implicitProjectConfig.checkJs at the repository level (committing .vscode/settings.json) and add either @ts-check or @ts-nocheck to the top of every JavaScript file – the repository setting is ignored when the folder is opened within a broader workspace.

Functions

Functions encapsulate much of a program’s logic, so how they are named and structured goes a long way toward self-documenting code (see Naming conventions). The use of functions as higher-order functions is covered in Functional programming.

Function declarations versus expressions

There are two ways to define a function in JavaScript: a function declaration (using the function keyword) and a function expression (typically an arrow function assigned to a const).

// Function declaration.
function fooBar () {
  // ...
}

// Function expression.
const bazQux = () => {
  // ...
}

Function declarations can be overwritten within the same module; function expressions assigned to a const cannot. Arrow functions cannot be used as constructors (which is desirable) and do not bind their own this, so they cannot be rebound with Function.prototype.bind().

Our policy is to use function declarations only where consumers need to change the call context (by dynamic binding of this) or where the function is meant to be used as a constructor. In all other scenarios, use function expressions assigned to const.

Arrow functions

The simplest arrow function moves the arguments to the left of and the return expression to the right:

(x) => x + 1

// Equivalent to:
function (x) {
  return x + 1
}

Line breaks are permitted after the arrow, but not before. Where the body is a block, an explicit return is required:

const fn = (x) => {
  return x + 1
}

this, call, apply, and bind

A function that references this is called as a method when it is looked up as a property and immediately invoked. call and apply let you invoke a function with an explicit this and arguments:

speak.call(rabbit, 'Hello!')
speak.apply(rabbit, ['Hello, everyone!'])

apply takes arguments as an array; call takes them separately. If the function needs no context, pass null as the first argument. bind returns a new function permanently bound to a given this. Arrow functions do not bind their own this (they inherit it lexically), so call, apply, and bind cannot rebind them – which is why arrow functions are preferred for functional programming (see Functional programming).

Closures and IIFEs

A closure is formed when an inner function retains access to an outer function’s variables after the outer function has returned. Closures are useful for encapsulating private state, but they can also hide circular references that cause memory leaks (see Quality assurance).

Immediately-invoked function expressions (IIFEs) were traditionally used to create private scope and modules:

(function () {
  // ...
}())

With standard ECMAScript modules, IIFEs are no longer needed for scoping or modules. They MAY still appear in legacy code or in configuration files that run in non-module contexts.

Parameters

The spread operator (…​) captures all remaining arguments into a single array, replacing the legacy arguments object:

function eg (...args) {
  console.log(args)
}

Default parameters and rest parameters SHOULD be used in preference to manual argument checks.

Return values

Unless the structure of a return value is obvious from the function name (e.g. getCoordinates()[lat, lng]), return an object so that consuming code can destructure by name:

return { template, id }

Loops

Labelled loops

It is rarely used, but you can use labeled statements to identify loops. This is useful in nested loops, as it allows you to specify which loop to continue or break out from.

function loop () {
  dance:
  for (let i = 0; i < 4; i++) {
    for (let j = 0; j < 4; j++) {
      if (j === 2) {
        break dance
      }
    }
  }
}

Objects and classes

Objects

JavaScript objects are soft: members can be added and modified at any time. This is more flexible than the hard objects of class-based languages, and it means deep inheritance hierarchies are rarely needed – shallow hierarchies tend to be more efficient and expressive.

Developers MUST NOT modify or extend objects they do not own. In particular, never change the APIs of native objects (Array, Math, Object, etc.) or host objects (such as the DOM in web browsers) – this is widely regarded as bad practice.

Types of objects

Every object in a JavaScript program is one of:

  • Native objects – defined in the ECMAScript specification and available in every runtime (Array, Boolean, Date, Error, Function, Math, Number, RegExp, String, Object, Symbol).
  • Host objects – provided by the host environment rather than the language; their APIs are not defined by ECMAScript. In browsers these include window, document, HTMLElement, Event, XMLHttpRequest, and the web APIs; in Node they include process, Buffer, and the built-in modules (see Runtimes).
  • User-defined objects – created by application developers or by third-party libraries and frameworks. These are the building blocks of JavaScript applications.

All objects derive from Object; even functions are objects.

Kinds of properties

JavaScript distinguishes three kinds of properties:

  • Named data properties – the most common kind; any property with a value, including methods (whose value is a Function).
  • Named accessor properties – a property backed by a getter or setter.
  • Internal properties – used by the engine, referred to in double square brackets (e.g. ); not directly accessible via the language API (though is retrievable via Object.getPrototypeOf()).

Property attributes

All properties have enumerable and configurable attributes. Named data properties also have value and writable; named accessor properties have get and set. Non-enumerable properties are skipped by for…​in and Object.keys(); non-configurable properties cannot have their attributes changed (except value) via Object.defineProperty().

Definition versus assignment

Defining a property is not the same as assigning it. Definition (Object.defineProperty()) adds an own property with explicit attributes. Assignment (obj.prop = value) either invokes a setter found on the object or its prototype chain, or creates an own property with default attributes. Assignment never changes properties in prototypes, only own properties.

Use assignment to change the value of an already-defined property, and definition to create fresh properties with specific attributes. A read-only property in a prototype prevents assignment from creating an own property of the same name (in strict mode it throws), but Object.defineProperty() can still override it – be wary of this, as it is a source of subtle bugs.

'use strict'

const proto = {
  get bar() {
    return 'bar'
  }
}

const obj = Object.create(proto)
obj.bar = 'foo' // TypeError: obj.bar is read-only

Object.defineProperty(obj, 'bar', { value: 'foo' })
obj.bar // 'foo'

Object APIs

Object.keys(obj) returns the enumerable own property names; Object.getOwnPropertyNames(obj) returns all own property names, enumerable or not. Object.create(proto, props) creates a new object with proto as its prototype – a shortcut for prototypal inheritance (see Inheritance).

Object.seal(obj) prevents new properties from being added and existing property descriptors from being changed or deleted, but property values can still be modified. Object.freeze(obj) does all of that and also makes property values non-writable – it is the way to make an object fully immutable in JavaScript. Once frozen, an object cannot be unfrozen.

Object equality

Objects are compared by reference, not by value. Two objects with identical properties are not equal unless they are the same instance:

const a = { foo: 'bar' }
let b = { foo: 'bar' }

a === b // false
b = a
a === b // true

To compare two objects by value, implement a function that enumerates properties and compares their values (or use a library).

toString()

toString() returns a string representation of an object, useful for debugging. Custom types SHOULD override it to produce a meaningful representation:

class Color {
  constructor(r, g, b) {
    this.r = r
    this.g = g
    this.b = b
  }
  toString() {
    return `rgb(${this.r}, ${this.g}, ${this.b})`
  }
}

const red = new Color(255, 0, 0)
red.toString() // 'rgb(255, 0, 0)'

Property access

Use dot notation by default (obj.prop). Use square-bracket notation when the property name is computed at runtime, is not a valid identifier, or contains characters that dot notation cannot express:

obj.prop
obj[computedName]
form.elements['name[]']

delete

The delete operator removes a property from an object – it removes the property itself, not just its value:

delete cat.age

Classes

Classes (introduced in ES2015) are the preferred construct for encapsulating related data and functionality. They are syntactic sugar over JavaScript’s prototype-based inheritance model, but with real advantages over constructor functions: they guarantee an initialization function is called, static properties are inherited, and built-in constructors – including exotic ones like Array – can be subclassed. Class constructors cannot be invoked without new.

class MyClass {
  constructor() { /* ... */ }
  method1() { /* ... */ }
  method2() { /* ... */ }
}

No commas separate class members (unlike object literals).

Declarations and expressions

Classes support both declarations and expressions, named or unnamed:

// Declaration.
class Area {
  constructor(height, width) {
    this.height = height
    this.width = width
  }
}

// Expression (unnamed).
const Area = class { /* ... */ }

// Expression (named).
const MyArea = class Area { /* ... */ }

Unlike functions, class declarations and expressions are not hoisted; a class MUST be declared before it is used. The body of a class is always executed in strict mode.

Constructors, methods, and fields

The constructor is a special method for creating and initializing an instance; there can be only one per class. Prototype methods and getters are defined in the class body as usual. Static methods and properties belong to the class itself, not instances:

class Point {
  constructor(x, y) {
    this.x = x
    this.y = y
  }
  static distance(a, b) {
    const dx = a.x - b.x
    const dy = a.y - b.y
    return Math.hypot(dx, dy)
  }
}

const p1 = new Point(5, 5)
const p2 = new Point(10, 10)
Point.distance(p1, p2)

Class fields may be declared at the top level of a class body; their types need annotation only where the type cannot be inferred from an assigned value.

Private members

Authors MUST NOT use TypeScript’s private modifier:

class Person {
  private name: string

  constructor(name: string) {
    this.name = name
  }

  introduce() {
    return `Hello, my name is ${this.name}`
  }
}

Since v3.8, TypeScript has supported ECMAScript private fields, which use a # prefix to distinguish private data from public:

class Person {
  #name: string

  constructor(name: string) {
    this.#name = name
  }

  introduce() {
    return `Hello, my name is ${this.#name}`
  }
}

The TypeScript compiler keeps # private fields private in build artifacts, while TypeScript’s private modifier is used only for error checking at compile time. Private fields are therefore RECOMMENDED as the stricter, more future-proof implementation.

Inheritance

Subclasses incorporate another class’s state and behavior using extends and super:

class Animal {
  constructor(name) {
    this.name = name
  }
  speak() {
    return `${this.name} makes a sound`
  }
}

class Dog extends Animal {
  constructor(name) {
    super(name)
  }
  speak() {
    return `${this.name} barks`
  }
}

Any use of a superclass instance SHOULD be substitutable by a subclass instance – the Liskov Substitution Principle. Beware overuse of inheritance: too much inheritance creates monolithic classes that dilute encapsulation and are hard to reuse. Where inheritance and composition can both reasonably express a design, prefer composition. JavaScript uses prototypal inheritance (objects inherit directly from other objects via delegation), not classical inheritance; deep hierarchies are rarely needed, and shallow designs are the norm.

Class design best practices

  • Each class SHOULD be responsible for one thing and have one reason to change.
  • Code to an interface (a "contract") rather than concrete types.
  • Avoid tight coupling; dependencies SHOULD be injected, preferably via constructor injection against an interface.
  • Favor cohesion, loose coupling, encapsulation, testability, readability, and focus.

Modules

Standard ECMAScript modules (ESM) MUST be used exclusively for all code. Authors MUST NOT write CommonJS modules – Node’s default module system – or any other type of non-standard module system such as AMD or UMD. This means using import and export exclusively for all module loading and exporting, rather than require and module.exports.

Note

All JavaScript files MUST use the .js file extension, and not the .mjs extension. TypeScript files MUST use the .ts file extension.

// ES MODULES

// Import specific components into an ES module.
import { AssertionError, RuntimeError } from '@package/err'

// Or import all components from package into an ES module.
import * as err from '@package/err'
// COMMONJS SCRIPTS

// Import specific components into a CJS script.
const { AssertionError, RuntimeError } = require('@package/err')

// Or import all components from a package into a CJS script.
const err = require('@package/err')

There MAY be rare exceptions where CommonJS modules are necessary. For example, some testing frameworks and other development tools still require configuration files in the CommonJS format. In these cases, the .cjs file extension MUST be used, to distinguish these files from standard ECMAScript modules (.js).

Although all major runtime environments, including browsers and server-side runtimes, now support ECMAScript modules, sometimes it may be necessary to transform ESM code before it can be consumed by other tools or environments. A static transcompiler like Babel or a runtime transcompiler like esm can be used for this purpose. To target web browsers, further "bundling" may be beneficial, to reduce distributable builds. An ESM-compatible bundler like Rollup or Webpack 2 may be used for this purpose.

Background

For many years in its early history, JavaScript did not have a native module system. Ecma International standardized ECMAScript Modules – aka ES Modules, ESM, or Harmony Modules – in the 6th edition of ECMAScript, finalized in 2015.

Within a couple of years, support for ECMAScript Modules started turning up in mainstream web browsers (via <script type="module">). But it took a few more years for the Node runtime to catch up. Node’s implementation of ES Modules was not marked as "stable" until the following releases:

We can now write code in standard JavaScript modules and run it directly in Node without a build step or a runtime transcompiler. Node also supports applications that mix-and-match ESM and CJS files, so existing applications can be incrementally upgraded from CJS to ESM.

Legacy JavaScript applications SHOULD be incrementally refactored to ESM, too, for better future-proofing. Libraries that are intended for use in applications or runtime environments that require CJS modules MUST be distributed as hybrid packages: a transcompiler such as Babel transforms the source from ESM to CJS, and conditional exports (see Packages and tooling) make both module formats available.

Imports

A bare specifier is a file or folder name without a provided path. These are often used to import package names, because it is easier to type a package name than write out a full file path.

// A bare specifier. Notice how it's easier to import from `react`
// than from `../../node_modules/react/index.js`.
import React from 'react'

// This is not a bare specifier: the `./` means check the current
// directory for a `FooComponent.js` file.
import FooComponent from './FooComponent.js'

Unlike CJS’s require(), ESM’s relative imports do not resolve a directory to its index.js file automatically. You MUST specify the file name:

import database from './database/index.js'

Internal imports

In large applications, it is not unusual to see imports of internal components like this:

import { forIn } from '../../../../../utils/array'

It is neater to import internal components using paths relative to some base root directory, such as src. Besides neatness, absolute paths for internal imports give a unique, searchable path for every import instance of any given module, making it easier to find everywhere a module is used.

For JavaScript applications, there are several solutions. Node has an environment variable NODE_PATH that extends the locations where the runtime looks for modules referenced via require() (it does not affect ESM import):

{
  "scripts": {
    "start": "NODE_PATH=src/ node main.js"
  }
}

Alternatively, transpilation or bundling tools like Webpack can be configured to define path aliases:

{
  "resolve": {
    "extensions": [".js", ".vue", ".json"],
    "alias": {
      "@": path.join(__dirname, "..", "src")
    }
  }
}

In TypeScript, this is easier still. TypeScript has a path mapping option that maps arbitrary filesystem paths – anything that does not start with / or . – to physical paths. Path maps are defined in tsconfig and resolved automatically by the compiler:

{
  "compilerOptions": {
    "baseUrl": "./src",
    "paths": {
      "@app/utils/*": ["app/utils/*"],
      "@app/pipes/*": ["app/pipes/*"]
    }
  }
}

The path mappings are defined in the paths property, all relative to the baseUrl:

import { forIn } from '@app/utils/array'

The RECOMMENDED convention for importing internal application modules is to use the ~ path prefix. By using a different character entirely, it is easier to distinguish between internal imports (~) and external ones (@): ~ always resolves to src/, while @ always resolves to node_modules/.

import { forIn } from '~utils/array'

This convention MUST be used only for in-house applications. It MUST NOT be used within public libraries, where relative file paths MUST be used.

Including JSON

In Node.js, import JSON files as modules using an import attribute, which keeps the JSON statically analyzable:

import config from './config.json' with { type: 'json' }

Where the runtime does not support JSON import attributes, read and parse the file with fs and JSON.parse(). Loading JSON via require() is CommonJS and MUST NOT be used (see Modules). Be aware that module imports are cached: the same file is not re-read on subsequent imports. For files that must always be fresh (such as test fixtures), read and parse the file directly, preferably in a non-blocking manner:

const fs = require('fs')

fs.readFile('./config.json', (err, data) => {
  if (err) throw err
  let config = JSON.parse(data)
  console.log(config)
})

Or synchronously, if you must:

const fs = require('fs')

let rawdata = fs.readFileSync('config.json')
let config = JSON.parse(rawdata)
console.log(config)

Exports

Default versus named exports

ES modules support two types of export: named exports and default exports. A module can have both:

export const A = 'A'
export default A

Default exports do not export any name (symbol) that can be easily associated with the exported value. Named exports are all about having a name.

Use of default exports is generally considered bad practice. The JavaScript language supports default exports only to provide easy interoperability with legacy non-standard module conventions, notably Node’s CommonJS API – require() and module.exports. Default exports are intended only to provide an easy upgrade path to the standard module format. New libraries and applications SHOULD use named exports.

// Do not do this.
export default {
  propertyA: 'A',
  propertyB: 'B',
}

// Do this instead.
export const propertyA = 'A'
export const propertyB = 'B'

Named exports are preferred over default exports for the following reasons:

  • Named exports MUST be imported with reference to the identifier they are exported as. This makes large-scale refactoring easier, because every component has a unique identifier wherever it is used, and typos are easier to spot.
  • Named exports enable better "tree shaking" of unused exports, making compiled artifacts smaller. A single export object with multiple resources referenced via properties prohibits tree shaking.
  • Named exports are more scalable: an existing module can be easily extended with additional exports.
  • IDE tooling can more accurately cross-reference components in modules that have multiple named exports, and is less reliable where a module exports a default object with multiple properties.

Mixing default and named exports in the same module is bad practice, though it is permitted by the specification. It also makes it difficult to offer CJS versions of ESM modules, because CJS modules only have a single module.exports object.

Named exports

Named exports allow the name of a class, function, or variable to be transferred into consuming scripts. In consuming files, imports cannot be any randomly assigned identifier and MUST match the name in the source module:

// CommonJS
exports.FooBar = class FooBar {}
const FooBar = require('./foobar').FooBar

// ES
export class FooBar {}
import { FooBar } from './foobar.js'

A rare problem with named imports is conflicting identifiers – when a consuming script imports two unrelated dependencies that happen to have the same name. ES provides aliasing for this:

import { speak as cowSpeak } from './cow.js'
import { speak as goatSpeak } from './goat.js'

Named imports SHOULD NOT be aliased except to resolve identifier conflicts. At least with aliases you still reference the imports' canonical names, and the decision to rename the import is made explicit.

Exporting functions

Where a function does not need its this binding to be rebindable by consumers, or to be used as a constructor, use a function expression assigned to const:

const fooBar = () => {
  // ...
}

export { fooBar }

Use a function declaration only where consumers need the flexibility of changing the call context (by dynamic binding of this) or where the function is meant to be used as a constructor:

function fooBar () {
  // ...
}

export { fooBar }

CommonJS

At the time of Node’s first release in 2009, JavaScript did not have a native module system, so Node adopted CommonJS Modules (CJS) – a community-derived module system implemented as extensions to JavaScript’s standard API: the require() function and the module.exports object (and its alias exports).

Although we SHOULD write all new JavaScript components with standard import/export syntax, some use cases still require CommonJS files – for example, some development tools still accept configuration files in the CommonJS format only.

Node wraps the contents of .js files in a function, scoping local variables to the file with only exports, require, and module provided as references:

(function (exports, require, module, __filename, __dirname) {
  // ./lib/<modulename>.js
})

The module variable is an object representing the module. You attach your module’s public API to its exports property. By default, module.exports is an empty plain object that gets returned from require() when the module is imported. The exports variable is just a reference to module.exports.

Be careful not to reassign exports, because doing so breaks the reference to module.exports. This function will not be exported:

exports = function () {}

If you replace module.exports with a new object, function, or class, and you still want to keep the exports shorthand, you must re-point exports to the new object:

exports = module.exports = function () {}

The safest option is to always use module.exports and never its alias exports.

Module systems in Node

Toggling interpreters

Behind the scenes, Node has two JavaScript interpreters: its legacy interpreter for CJS scripts and a newer one for ES modules. At runtime, Node toggles between them depending on which module format it encounters in each file.

By default, Node interprets all .js files as CommonJS scripts. This default is necessary for backwards compatibility: ES modules behave differently (they are loaded asynchronously and are strict-mode compliant by default), so switching the default would require extensive refactoring of existing libraries and applications. It is the same reason modern web browsers continue to assume that inline and imported scripts are non-ESM, requiring authors to opt in explicitly via type="module".

The easiest way to opt in to Node’s ESM interpreter is to change a file’s extension from .js to .mjs. This is a non-standard extension, and many development tools – notably TypeScript – do not recognize it. The explicit toggle is therefore NOT RECOMMENDED.

The RECOMMENDED way to toggle Node’s interpreter is to set the following property in package.json:

{
  "type": "module"
}

With this in place, all .js files within the package.json directory and its subdirectories run through the ESM interpreter. The property can be set on the root manifest or on a directory-by-directory basis via nested package.json files containing just { "type": "module" }.

This toggles Node’s default interpreter for .js files from CJS to ESM. You can still mix-and-match: change the extension of CommonJS scripts to .cjs, and Node toggles its interpreter back for those files only. This is the RECOMMENDED methodology because:

  • The standard .js extension indicates a standard ECMAScript module or script, while the non-standard .cjs extension indicates a non-standard CommonJS module.
  • All .js files remain compatible with TypeScript applications.
  • Code refactoring is minimised – a single configuration line toggles the interpreter.
  • Migration from CJS to ESM is easier to manage.

Importing CJS into ESM

In Node, ESM files can import CJS files in the same way they import other standard JavaScript modules. As a general rule, when you import CommonJS components from ES modules, you SHOULD import the whole module using the default import syntax, because CJS scripts have no concept of named exports:

import _ from 'lodash'

From Node v12.20.0 and v14.13.0, Node supports detection of CommonJS named exports, so it is sometimes possible to import only specific exports from a CommonJS-only package:

import { Router } from 'express'

This does not always work – for example, import { shuffle } from 'lodash' fails. There is risk involved: a simple refactoring of a CJS script’s source could unwittingly break Node’s automatic detection of its module.exports properties. It is strongly RECOMMENDED to import whole CommonJS scripts, even at the cost of importing more than you need. You can still use destructuring to access specific properties:

import _ from './lodash'
const { shuffle } = _

Wherever possible, we MUST prefer to consume ESM over CJS. Where two competing libraries provide the same features, prefer the ESM package; ESM enables tree-shaking of unused imports from build artifacts.

Importing ESM into CJS

It is also possible to import modern ES modules into legacy CJS scripts. Because ES modules are loaded asynchronously and Node’s require() is synchronous, a different API is needed: dynamic imports, also known as import() expressions:

const foo = await import('./foo')

Within ES modules – where top-level await is supported – await import() can exist at the top level. CJS scripts do not support top-level await, so await import() MUST be wrapped in an async block:

(async () => {
  const foo = await import('./foo')
  // ...
})()

Or using promise chaining:

import('./foo')
  .then((foo) => {
    // ...
  })
  .catch((err) => {
    // ...
  })

Importing ES modules into CJS scripts gets messy. For this reason, it is RECOMMENDED to upgrade CJS scripts to ESM only when all of the module’s dependencies are also readily available as (or easily upgraded to) ESM. The migration from CJS to ESM SHOULD begin in low-level libraries and incrementally work up to higher-level components and applications.

Many development tools are still written in CJS and have not been updated to accept ES modules – for example, neither Mocha nor ESLint accept ESM configuration files. Most of the time workarounds exist (both Mocha and ESLint accept .cjs configuration files); in the worst case, a transcompiler or some bridging code is needed. Only a handful of legacy packages – such as proxyquire and rewire, which monkey-patch require() for testing – cannot be made to work with ES modules at all; Sinon, testdouble.js, and rewiremock offer alternatives that support both CJS and ESM.

Module systems for the web

All major web browsers now implement the standard JavaScript module system via <script type="module">. The same ESM conventions apply on the web as on the server. Where the cost of many separate module requests outweighs the benefit, modules MAY be bundled for delivery to the browser using an ESM-compatible bundler.

No inline scripts

All JavaScript MUST live in .js files loaded via <script type="module"> (or a bundler output), not inline in HTML. Inline <script>…​</script> blocks and inline event handlers (onclick, onload, and similar HTML on* attributes) MUST NOT be used. Inlining imperative logic in HTML makes it harder to test, harder to reuse across pages, and impossible to lint or cache independently. Keep HTML declarative and JavaScript imperative: the HTML describes what the page contains, the scripts describe how it behaves.

This rule is what makes the ES-modules-only stance above load-bearing on the web: there is no second, inline channel for JavaScript to reach the page.

Passing server data to client scripts

A common anti-pattern is to inject server-side data into the page via an inline <script> that assigns to a global:

<script>window.userData = { email: 'john@example.com', id: 9283 }</script>

This reintroduces both inline scripts and global state, which this standard prohibits. Server-rendered data MUST instead be passed to client scripts through the HTML itself, in one of two ways.

For data consumed by a single component, embed it in the component’s markup as a data-* attribute and let the behavior read it:

<div class="user-info" data-user-info='{"email":"john@example.com","id":9283}'>
</div>
const el = document.querySelector('[data-user-info]')
const data = JSON.parse(el.dataset.userInfo)

For data shared across multiple components, emit it as <meta> tags in the document <head> and read them with a small helper:

<meta name="app:user-data:email" content="john@example.com">
<meta name="app:user-data:id" content="9283">
function getMeta (name) {
  const el = document.querySelector(`meta[name="${CSS.escape(name)}"]`)
  return el?.content
}

getMeta('app:user-data:email') // 'john@example.com'

This keeps the HTML declarative and the JS imperative, and it avoids both inline scripts and global variables.

Dynamic imports and code splitting

Static import declarations are evaluated at compile time, which enables static analysis, bundling, and tree shaking. For runtime-driven loading – a module chosen by user language, lazy-loading for performance, or robustness against a non-critical module failing to load – use the dynamic import() expression (standardized in ES2020). It returns a promise for the module namespace object, and its specifier is evaluated at runtime, so it need not be a string literal:

const mod = await import(`./i18n/${navigator.language}.js`)

Bundlers such as Rollup and Webpack automatically code-split at import() boundaries, producing separate bundles that load on demand. This keeps the initial bundle small by deferring code the user may never need.

Packages and tooling

Distributable packages are built from source code kept in a source control repository. Build scripts produce artifacts to a directory called dist, and these artifacts are subsequently deployed via a centralized package registry.

Filesystem structure of packages

Every package MUST have:

  • A package manifest (package.json).
  • A README (written in Markdown).
  • A plain text LICENSE file.
  • Either a lib directory (via which the package’s programmatic API is exported) or a bin directory (via which the package’s CLI is made available), or both.

A package MAY include:

  • A CHANGELOG file (written in Markdown).
.
│
├─ bin/*
├─ lib/**/*.js
├─ CHANGELOG.md
├─ LICENSE.txt
├─ README.md
└─ package.json

Package manifests

The package.json file is a de facto manifest standard for JavaScript packages. It is not part of the ECMAScript specification; rather, it evolved by convention, originally for the NPM package manager and subsequently extended by Node and other tools.

In general, package.json files serve two purposes: they provide metadata for shareable packages, and they define development dependencies, run-scripts, and other configuration for the development, testing, building, and distribution of those packages. These concerns SHOULD be separated, with a package manifest distinct from the repository manifest (see Repository manifests). This keeps manifests simpler and means production dependencies never co-exist with devDependencies in the same node_modules directory, which makes production dependency trees easier to audit.

This is the template for package manifests:

{
  "name": "@[namespace]/[pkgname]",
  "version": "1.0.0-alpha.0",
  "description": "A short description.",
  "keywords": [
    "key",
    "words"
  ],
  "author": "Your Org <dev@example.com>",
  "contributors": [
    "First LastName <dev@example.com>"
  ],
  "license": "MIT",
  "homepage": "https://github.com/[organisation]/[repo]#readme",
  "repository": {
    "type": "git",
    "url": "https://github.com/[organisation]/[repo].git"
  },
  "bugs": {
    "url": "https://github.com/[organisation]/[repo]/issues"
  },
  "files": [
    "CHANGELOG.md",
    "LICENSE.md",
    "README.md",
    "lib/**/*"
  ],
  "engines": {
    "node": "^<maintenance-lts-1>.x.x || ^<maintenance-lts-2>.x.x || ^<active-lts>.x.x"
  },
  "type": "module",
  "bin": {
    "cmdName": "./bin/cmd-name"
  },
  "main": "./lib/cjs/index.js",
  "module": "./lib/esm/index.js",
  "exports": {
    ".": {
      "require": "./lib/cjs/index.js",
      "import": "./lib/esm/index.js"
    }
  },
  "dependencies": {},
  "peerDependencies": {}
}

Most of these fields provide metadata for package management tools and registries, based on NPM’s specification. Node extends this with type, main, exports, and imports, which determine how packages are imported and interpreted at runtime.

name and version

The name field specifies a unique identifier for a package. It is REQUIRED for publicly-distributed packages, and MUST be unique among all packages in the registry via which it is distributed. Package names can be global (e.g. lodash-es) or scoped (@[scope]/[name], e.g. @babel/core). It is strongly RECOMMENDED that all new packages be distributed with scoped names, since only the scope MUST be unique in the registry.

A version is REQUIRED for packages distributed via public registries. See Dependency management for guidance on version constraints.

description, keywords, author, and contributors

These fields are OPTIONAL and are used only to make packages easier to find in registries.

license, homepage, repository, and bugs

These fields provide metadata linking to the package’s license, homepage, source repository, and issue tracker.

files

The files field whitelists the files included in the published package archive. It SHOULD be used to keep published artifacts small and to avoid leaking development-only files.

engines

The engines field specifies with which versions of Node a package is compatible. For publicly-distributed packages, it is our policy to support only the active and maintenance LTS releases of Node. At any time there are usually three supported versions; only when a "current" release becomes the active LTS release do we bump support. See Runtimes for further guidance.

type

The type field is used by Node as an "implicit toggle" for its module interpreter. By default, Node interprets all .js files as CommonJS scripts. If a package is mostly composed of ES modules, this field SHOULD be set to "module" so all .js files are treated as ES modules. The possible values are "commonjs" (the default) and "module".

All package manifests MUST include the type field, even when the value is "commonjs". This future-proofs the package and makes it easier for tools to determine how .js files should be interpreted. See Modules for background on the type field.

bin

The bin field maps command names to executable scripts. See Package command line interfaces.

main, module, and exports

These fields configure the entry points to a package. See Package exports.

dependencies and peerDependencies

Only production dependencies and peerDependencies SHOULD be defined in package manifests. devDependencies SHOULD be used only in repository manifests.

scripts

The scripts field configures run-scripts that automate phases of the development, testing, and release lifecycle. It SHOULD NOT be included in package manifests, only in repository manifests (see Repository manifests).

Package exports

There are three fields for defining entry points to a package: main, module, and exports. The information in these fields is used by Node and by bundlers such as Webpack and Rollup to resolve "bare specifier" import, import(), and require() expressions. These fields affect the resolution of bare specifier imports only.

// Bare specifier
import { pkgName } from '@pkg/name'

// Relative specifier
import { config } from '../config.js'

main

The main field is Node’s legacy methodology for defining a package’s entry point. It can define only a single entry point.

{
  "main": "./lib/index.js"
}

If neither main nor exports is defined, Node will attempt to resolve the package to index.js or index.node.

module

For years after ESM was standardized but before Node supported it natively, bundlers such as Webpack and Rollup drove a convention to use a custom module property to point to the ESM entry point, with main pointing to the CommonJS entry point:

{
  "main": "./lib/cjs/index.js",
  "module": "./lib/esm/index.js"
}

The module convention has largely been superseded by exports, but publicly-distributed libraries MAY maintain this field for the benefit of consuming applications that run on deprecated Node lines or use older transpilation toolchains.

exports

Since v12.20 and v14.13, Node has supported an exports field (an "export map") with far more extensive capabilities than main. Where exports is present, it overrides main and prevents consumers from importing entry points that are not explicitly defined. The exports field allows packages to have multiple entry points and conditional exports.

The simplest form defines a single entry point:

{
  "exports": "./lib/index.js"
}

More commonly, exports is an object whose default entry point is keyed by .:

{
  "exports": {
    ".": "./lib/index.js",
    "./feature1": "./lib/feature1/index.js",
    "./feature2": "./lib/feature2/index.js"
  }
}

Patterns are possible too:

{
  "exports": {
    "./feature/*": "./lib/feature/*.js"
  }
}

Consumers append the subpath to the package’s bare specifier:

import feature1 from '@namespace/package/feature1'

Because exports encapsulates the package, introducing it can be a breaking change for consumers who relied on implicit entry points. To make the introduction non-breaking, all old implicit entry points MUST be defined as explicit exports:

{
  "exports": {
    ".": "./lib/index.js",
    "./feature": "./feature/index.js",
    "./feature/*": "./feature/*.js",
    "./package.json": "./package.json"
  }
}

Encapsulation can be disabled entirely:

{
  "main": "./lib/main.js",
  "exports": {
    ".": "./lib/main.js",
    "./*": "./*"
  }
}

This is NOT RECOMMENDED: it increases the surface area for misuse, loses encapsulation, and makes testing harder. It MAY serve as a pragmatic interim step when migrating from main to exports and consumption patterns are uncertain.

Conditional exports

Conditional exports define different entry points for different target environments. This is most useful for hybrid packages that export modules in both ESM and CJS formats, letting Node load the appropriate version automatically:

// CJS
require('@namespace/package')

// ESM
import '@namespace/package'

A minimal hybrid package:

{
  "exports": {
    "require": "./index.js",
    "import": "./index.js"
  }
}

Conditional exports can be combined with multiple entry points:

{
  "exports": {
    ".": {
      "require": "./lib/cjs/index.js",
      "import": "./lib/esm/index.js"
    },
    "./feature": {
      "require": "./lib/cjs/feature/index.js",
      "import": "./lib/esm/feature/index.js"
    }
  }
}

Hybrid packages MAY use the conventional .js extension for both CJS and ESM files; the require and import keys of exports are enough to select the correct interpreter. When developing hybrid packages, all exported modules MUST be stateless: because ESM and CJS run through different interpreters, an application could import two separate instances of the same module, and a stateful module could then behave unexpectedly.

Putting it together

A combination of main, module, and exports supports a very wide range of runtimes:

{
  "main": "./lib/cjs/index.js",
  "module": "./lib/esm/index.js",
  "exports": {
    ".": {
      "require": "./lib/cjs/index.js",
      "import": "./lib/esm/index.js"
    }
  }
}

For publicly-distributed packages that may run in legacy runtimes, a configuration similar to this – with exports and main, and possibly module – is RECOMMENDED. But there is a cost to maintaining many ways to consume a package: if support for old runtimes or bundlers is not needed, the main and module fields SHOULD be dropped, and the require condition on exports MAY be dropped too.

Package imports

Node’s imports field defines "subpath imports" for a package. Each top-level property is prefixed with # to disambiguate subpath imports from package specifiers:

{
  "imports": {
    "#dep": {
      "node": "dep-node-native",
      "default": "./dep-polyfill.js"
    }
  },
  "dependencies": {
    "dep-node-native": "^1.0.0"
  }
}

Within the package itself:

import '#dep'

This resolves to dep-node-native, falling back to ./dep-polyfill.js relative to the package.json file if that is unavailable.

Package command line interfaces

If a package is to be used via the command line, place the relevant scripts in a bin directory and map command names to them via the bin field:

{
  "bin": {
    "cmdName": "./bin/cmd-name"
  }
}

The bin property maps commands to scripts; npm symlinks each script, placing the symlink in ./node_modules/.bin/ for local installs. A package may ship multiple command line tools. Omit the .js file extension from files in bin.

Within scripts, use process.argv to fetch arguments entered in the terminal after node <commandname>:

#!/usr/bin/env node

'use strict'

const path = require('path')
const fs = require('fs')

const args = [null, null]
if (process.argv.length > 2) {
  args[0] = Number(process.argv[2])
  args[1] = Number(process.argv[3])
}

const lib = path.join(path.dirname(fs.realpathSync(__filename)), '../lib')
const out = require(lib + '/modulename').add(args[0], args[1])
console.log(out)

Distributing packages

Before publishing updates to a package, you MUST test it extensively – not just by running the test scripts, but by fully testing the end-to-end journey of installing and consuming the package. You SHOULD test locally before uploading to the NPM registry.

Test the package via its command line interface, then verify the package archive with npm pack, which generates a <packagename>-<version>.tgz archive:

$ npm pack

From another empty directory, install the archive locally and interact with it:

$ npm install ./path/to/packagename-1.0.0.tgz
$ ./node_modules/.bin/<commandname> arg1 arg2

To publish to the NPM registry, you must be an NPM user. Register once via npm adduser, then from the directory containing the package’s package.json:

$ npm publish

Run this whenever you want to publish updates. To remove a package from the registry later:

$ npm unpublish --force <packagename>[@<version>]

Once published, a package or version can no longer be fully removed without breaking dependents; instead, mark it as deprecated to discourage use:

$ npm deprecate <packagename> "This package is deprecated"

The npm program maintains a local cache of downloaded packages. To ensure you install the very latest build, clean the cache first:

$ npm cache clean

Generally, local installs are preferred to global installs; use global installs cautiously and only for build tools used across multiple projects.

Vendor packages

All dependencies – especially production dependencies – MUST be carefully audited before being used. There are many examples of updates to vendor packages inadvertently breaking production applications, whether by accident or through malicious intent.

Good test coverage is REQUIRED to ensure that upgrades of vendor packages do not introduce unexpected regressions, and security auditing is REQUIRED every time a new vendor component is installed or an existing component is updated.

Repositories

This section provides general guidelines for the organization of source control repositories for ECMAScript applications and libraries, including the trade-offs of multi-repos versus mono-repos and filesystem conventions (bin, lib, src, etc).

Repository filesystem structure

The filesystem structure for repositories varies a little depending on whether the encapsulated packages are applications or libraries.

Libraries

This is the filesystem structure for the source control repositories of ECMAScript-based libraries:

.
│
├─ (dist)
│  └─ **/*
│
├─ docs
│  └─ en/**/*.md
│
├─ lib
│  └─ **/*.js
│
├─ (node_modules) - excluded from source control
│  └─ **/*
│
├─ run
│  └─ **/*.js
│
├─ src
│  └─ @[namespace]
│     └─ [pkgname]
│        ├─ bin
│        │  └─ **/*
│        │
│        ├─ lib
│        │  └─ **/*.js
│        │
│        ├─ specs
│        │  └─ **/*
│        │
│        ├─ test
│        │  └─ **/*
│        │
│        ├─ CHANGELOG.md
│        ├─ LICENSE.txt
│        ├─ package.json
│        └─ README.md
│
├─ srv
│  └─ **/*
│
├─ .editorconfig
├─ .gitattributes
├─ .gitignore
├─ LICENSE.txt
├─ README.md
└─ package.json

The top-level directories have the following uses:

  • dist. Temporary directory capturing compiled artifacts; excluded from source control.
  • docs. Documentation for developers and maintainers of the project.
  • lib. Custom libraries developed for the project. At the root of a repository, lib encapsulates custom scripts for the automation of development, compilation, testing, release, and other maintenance activities, commonly executed by run-scripts in the run directory.
  • run. Run-scripts referenced from the scripts section of the repository-level package.json manifest.
  • src. Source code. The sub-directory tree SHOULD be organized to mirror how the distributed packages will be installed in node_modules.
  • srv. Source files for the project’s public web site, if it has one.

The same directory structure is used for both multi-repos and mono-repos, so a repository can start with a single package and extend into a mono-repo without major refactoring:

// Multi-repo
.
└─ src
   └─ @[namespace]
      └─ [pkg1]

// Mono-repo
.
└─ src
   └─ @[namespace]
      ├─ [pkg1]
      ├─ [pkg2]
      └─ [pkg3]

The source code for each package’s libraries (the modules exported from the package) MUST be encapsulated in a sub-directory called lib. If the package exposes a CLI as well as an API, the binaries MUST be encapsulated in a sub-directory called bin. Automated test scripts MAY be included in an adjacent test directory, and behavioral specifications in a specs directory; these SHOULD be excluded from distributable artifacts.

Applications

The filesystem structure of the source control repositories for ECMAScript applications is similar. Applications additionally include source for the application’s deployment artifacts and runtime configuration.

Repository manifests

Each package SHOULD have its own package.json file, separate from the root-level manifest of the package’s originating source control repository. A package.json file SHOULD be either a repository manifest or a package manifest, never both. This keeps manifests focused and means production dependencies do not co-exist with devDependencies in the same node_modules directory, making production dependency trees easier to audit.

This is the template for repository manifests:

{
  "private": true,
  "name": "[project-name]",
  "workspaces": [
    "pkg/@[namespace]/*"
  ],
  "engines": {
    "node": "^<active-lts>.x.x",
    "npm": "^<npm-version>.x.x"
  },
  "type": "module",
  "devDependencies": {
    "@[namespace]/[package]": "^[major].[minor].[patch]",
    "[package]": "^[major].[minor].[patch]"
  },
  "scripts": {
    "clean": "node ./run/clean.js",
    "build": "node ./run/build.js",
    "lint": "node ./run/lint.js",
    "test": "node ./run/test.js"
  }
}
private

For repository manifests, the name and version fields SHOULD be omitted and private MUST be set to true. This prevents the repository from being accidentally published to a package registry – npm publish will fail if the manifest declares the package private. Most other metadata fields (description, keywords, author, license, homepage, etc.) can also be omitted.

name

The name field has no significance for a private manifest but is often read by development tools (e.g. prompt customizers render it in the terminal prompt). In a repository manifest, its value SHOULD be the name of the project.

workspaces

The workspaces key is picked up by package managers such as Yarn and npm and helps manage mono-repos – single repositories that store the source for multiple packages. See Workspaces.

engines

In a repository manifest, the engines field specifies which versions of Node and npm/yarn MUST be used in development and test environments. This SHOULD be more constrained than in package manifests. A package manifest’s engines describes the target production system; a repository manifest’s engines describes the baseline requirements for development environments.

{
  "engines": {
    "node": "^<active-lts>.x.x",
    "npm": "^<npm-version>.x.x"
  }
}
type

Since most source code is compliant with the ES module standard, it is RECOMMENDED to set "type": "module" at the repository level to toggle Node’s default interpreter. See Modules for background.

devDependencies

The devDependencies field SHOULD be managed by the package manager rather than edited manually.

scripts

The scripts field configures run-scripts that automate phases of the development, testing, and release lifecycle. It SHOULD NOT be included in package manifests but MAY be included in the top-level manifest of the original code repository.

The scripts object has a handful of special keys whose scripts are run automatically by npm and yarn in response to events (e.g. prepare and prepublish run on publish). More commonly, the scripts object references arbitrary scripts that automate repetitive processes: transpilation, linting, testing, and so on.

{
  "devDependencies": {
    "eslint": "^4.19.0",
    "jest-cli": "^22.4.2",
    "webpack": "^5.38.1",
    "webpack-cli": "^4.7.2"
  },
  "scripts": {
    "build": "webpack",
    "lint": "eslint .",
    "test": "jest"
  }
}

Run a script with the run command:

$ npm run build
$ yarn run build

Because CLI arguments get messy and package.json cannot be commented, each run-script SHOULD call a single JavaScript file, which then interacts with Node and the installed development dependencies via their programmatic interfaces:

{
  "scripts": {
    "clean": "node ./run/clean.js",
    "build": "node ./run/build.js",
    "lint:style": "node ./run/lint/style.js",
    "test": "node ./run/test.js"
  }
}
// ./run/clean.js
import fs from 'fs-extra'
import { fileURLToPath } from 'node:url'

const distdir = fileURLToPath(new URL('./dist', import.meta.url))
fs.emptyDir(distdir)

This approach is sometimes overkill or impossible – some tools (e.g. Flow) have no programmatic interface, and simple CLI utilities such as rimraf do the job perfectly well:

{
  "scripts": {
    "clean": "rimraf ./dist",
    "lint:types": "flow"
  }
}

Keep repository manifests as light as possible. JSON files cannot be commented, so configuration is hard to understand without prior knowledge. Wherever possible, pass configuration to devtools via alternative means – for example, prefer a separate .eslintrc.js file over putting ESLint’s config in the manifest.

Workspaces

Workspaces allow a single repository to manage multiple packages (a mono-repo). The workspaces field points the package manager at the package directories, which it then treats as local symlinks, so inter-package dependencies resolve to the local source rather than the registry. Lerna MAY be used as an OPTIONAL alternative for orchestrating builds and releases across workspace packages.

Package managers

Yarn is RECOMMENDED as the package manager for ECMAScript applications, libraries, and development toolchains that run in runtime environments like Node. Yarn and NPM – the built-in Node Package Manager – have broadly comparable feature parity, but Yarn is generally easier to use.

All contributors to an ECMAScript project SHOULD use the same package manager. Yarn and NPM resolve dependencies differently and can produce different dependency trees within the node_modules directory.

npx (bundled with npm) and yarn dlx execute packages without installing them permanently: they run a binary from the local node_modules/.bin, or fetch and run a package from the registry on demand. Prefer npx/yarn dlx over global installs for one-off commands and generators (e.g. npx create-react-app), so the latest version runs without a permanent global install.

Transpilation and bundling

Transpilation is source-to-source compilation: transforming modern JavaScript syntax (and polyfilling missing APIs) into a form supported by the target runtime. Modern runtimes support most recent syntax natively, so transpilation is needed only when targeting older runtimes or proposed features not yet widely supported.

Babel is the standard transpiler; the source remains standard JavaScript, transformed via @babel/preset-env to the configured targets. Prefer Babel over compile-to-JavaScript languages – it keeps the source as standard JavaScript and future-proofs it.

Bundling combines many modules into one (or a few) artifacts, reducing the number of requests needed to load a web application. Bundlers include Rollup, Webpack, and Parcel. Bundlers also enable tree shaking – eliminating unused exports – which requires ES modules. Use dynamic import() (see Modules) and bundler code-splitting to lazy-load code on demand rather than shipping one large bundle.

The goal is to transpile and bundle as little as possible: prefer native ES modules (loaded asynchronously per the spec) over transpiled, synchronously loaded bundles wherever the target runtimes support them.

When bundling is warranted, separate first-party application code from third-party vendor code into distinct bundles. Vendor code changes rarely and benefits from being cached independently of the application: a deploy that changes only application code does not invalidate the cached vendor bundle, and users do not re-fetch libraries they already have. This split also makes it easier to produce multiple application bundles that share one vendor bundle – for example, separate bundles for public pages and an authenticated dashboard. Modern bundlers support this via explicit entry points or cache-group configuration; do not rely on a single monolithic bundle.

Package design

Package cohesion. Classes within a package SHOULD be a closely related family and either all reusable or none of them; tightly coupled classes belong in the same package, and unrelated classes should be split out.

Package coupling. Prefer a dependency hierarchy where higher-order packages depend only on lower-order, simpler ones, and avoid dependencies between large packages, which risk circular references. Packages may iterate rapidly but their public APIs and behavior SHOULD be stable.

Do not aspire to add features to a package indefinitely; each package has a natural saturation point, after which it SHOULD be iterated for simplicity and to support infrastructure changes (avoiding software rot). Within packages, keep DRY; between packages, it is often sensible to repeat small bits of logic rather than introduce an inter-package dependency for a trivial task.

Dependency management

The following rules apply to the management of third-party ("vendor") libraries and packages, which may or may not be managed using a package manager tool.

Updating MAJOR versions

It is RECOMMENDED to use static analysis tools to detect libraries that have new major versions available, and to automatically raise issues in the project’s issue tracker for these updates.

LTS releases of dependencies SHOULD be chosen over non-LTS releases wherever possible.

For libraries that have Service Level Agreements (SLAs), major versions MUST be updated before their End of Life (EOL) date.

Updating MINOR and PATCH versions

Dependencies with MINOR/PATCH updates SHOULD be updated regularly – best practice is to have recurring tasks in the project’s issue tracker to cover this routine maintenance work.

It is RECOMMENDED to do MINOR/PATCH version updates at the start of a new release cycle. This means that dependency updates are not done close to release, leaving minimal time to reveal breaking changes and regressions caused by the dependency updates.

Dependencies MAY be updated as part of other development work, but it is RECOMMENDED to have dedicated issues assigned to these tasks.

Third-party packages

Vendor libraries SHOULD be used to solve specific problems in an application. Authors SHOULD NOT reinvent the wheel, solving problems that have already been solved by others.

At the same time, the number of dependencies in a codebase SHOULD be minimized. Too many dependencies can make it harder to maintain an application. Even when using package managers, authors SHOULD be careful to choose libraries that are well-maintained and have a good reputation.

Dependency injection

Dependency injection SHOULD be used whenever practical.

Dependency injection (DI) is a design pattern in which a component’s dependencies are provided to it by an external service that the component is not aware of. A component that needs to use a particular dependency does not also need to know how to create that dependency, separating the concerns of construction and use. DI also makes components easier to test, because it is easier to replace dependencies with fakes.

In JavaScript, an example of a class without dependency injection:

import { Database } from 'fictional-database';

class MyClass {

  constructor () {
    this.database = new Database().connect();
  }

  myFunction () {
    this.database.query();
  }

}

The version with dependency injection:

class MyClass {

  constructor (database) {
    this.database = database.connect();
  }

  myFunction () {
    return this.database.query();
  }

}

The DI pattern is complemented by the factory pattern. A factory is a function that creates objects. In the example above, a DatabaseFactory class could be created to assume responsibility for creating the database objects that are injected into the components that depend on them, like MyClass.

Version number constraints

JavaScript packages are versioned using a numbering system extended from the Semantic Versioning convention. A semantic version number consists of three parts:

  • Major version. Incremented when breaking API changes are made.
  • Minor version. Incremented when functionality is added in a backward-compatible manner.
  • Patch version. Incremented when backward-compatible fixes are made.

NPM/Yarn’s default behavior is to apply the ^ constraint on version numbers defined for installed dependencies. When you (yarn|npm) upgrade your dependencies, this constraint gets you the latest minor and patch versions, but never a higher major version. So ^2.3.4 automatically upgrades to all releases from 2.3.4 to <3.0.0.

If you want only the latest patch releases, prefix the version numbers with the tilde ~ character instead. ~1.2.3 constrains releases from 1.2.3 to <1.3.0.

Using either the ^ or ~ constraints increases demand for testing resources. End-to-end testing MUST be repeated whenever (yarn|npm) upgrade generates a new dependency lock file (yarn.lock or NPM’s package-lock.json). Using ^ or ~ places trust in the authors of the minor and patch releases of a dependency. For consumers of a publicly-distributed library to have the same trust in its authors, any update (not even patches) MUST NOT be blindly accepted into the production dependencies of a publicly-distributed package.

A bad dot release, deep within a dependency tree, can easily cause a chain reaction and introduce regressions in unexpected places. It is not enough to rely on automated unit tests to verify the correctness of dependency upgrades; extensive and manual end-to-end testing MUST be undertaken whenever dependencies are upgraded, even patch releases.

For this reason, it is RECOMMENDED to use the patch constraint (~1.2.3) rather than the minor constraint (^1.2.3) for production dependencies. The minor (and major) versions of production dependencies SHOULD be upgraded manually, only when there is a need to tap into new features of those dependencies. Minor version constraints (^) MAY be used for development dependencies (devDependencies), however.

Production dependencies and source control

As a general rule, package managers are used for installation of development dependencies, but production dependencies in JavaScript applications SHOULD be committed to source control rather than fetched from remote registries via package managers.

Wherever practical, all dependencies that application software needs to run SHOULD be committed to its source control repository. This guarantees that the application can be rebuilt at any point in its history to produce exactly the same build artifact. (This aim could also be achieved with package managers, for example via Yarn’s zero installs feature.) A second reason to avoid package managers for production dependencies is that installing dependencies manually makes engineers think twice about what they add, leading to a fuller understanding of the application’s dependency tree.

TypeScript

TypeScript MUST be used to enforce strong typing across all JavaScript code.

Any standalone JavaScript packages MUST export TypeScript type definition files (*.d.ts).

Type safety

JavaScript is weakly typed: it provides minimal support for enforcing that values assigned to variables and passed to functions match what the logic expects. Developers typically fall back to duck typing – if it looks like a duck and quacks like a duck, it probably is a duck – which has limited utility in guaranteeing correct types. The interpreter’s quiet type coercion and the ease of building ad-hoc object structures compound the problem, creating a whole class of bugs that do not exist in statically typed languages.

Type safety can be implemented manually, with typeof and instanceof checks or an assertion library:

import { assert } from 'some-assertion-library'

function truncate (str, maxlen, cont = '…') {
  assert(str).is.string()
  assert(maxlen).is.number()
  assert(cont).is.string()

  return (str.length > maxlen)
    ? str.slice(0, maxlen) + cont
    : str
}

This fails early and loudly, but only at runtime – and it bloats the function with code unrelated to its business logic. A static type system catches such errors far earlier, as the developer types, and with no runtime cost:

function truncate (str: string, maxlen: number, cont: string = '…'): string {
  return (str.length > maxlen)
    ? str.slice(0, maxlen) + cont
    : str
}

The stronger a language’s type system, the more classes of bug are eliminated at compile time. At the scale of modern JavaScript applications, weak typing becomes a hindrance – which is why TypeScript is used.

Embedding types in JSDoc notations

Where types cannot be expressed directly in TypeScript, they MAY be embedded in JSDoc notations. Importing a type inline keeps the annotation colocated with the declaration:

/** @typedef {import('glob').GlobOptions} GlobOptions */
/** @type {GlobOptions} */
const glob_options = {
  // …
}

The @typedef line can be omitted when the imported type is used only once:

/** @type {import('glob').GlobOptions} */
const glob_options = {
  // …
}

Using a subset of TypeScript

TypeScript SHOULD be used only for applications that are of a scale and complexity to benefit from the upgrade. Only a subset of the full TypeScript language SHOULD be used, sticking as much as possible with standard ECMAScript syntax and APIs over TypeScript-specific notation. For example, use ECMAScript’s # prefix for private fields rather than TypeScript’s private modifier (see Private members).

Since TypeScript is used as a development tool, projects SHOULD regularly upgrade to the latest major and minor release to benefit from TypeScript’s newest features. No specific minimum version is mandated; a recent, actively-supported release SHOULD always be used.

Operators

Non-null assertions

Since TypeScript 2.0, an exclamation mark ! may be placed immediately after expressions. This is the non-null assertion operator and it tells the type checker to assume that its operand is not null or undefined.

function processEntity(e?: Entity) {
  let s = e!.name // Assert that `e` is non-null.
  // ...
}

This operator was added because it is necessary in some domain contexts that the type checker does not understand, for example where components are instantiated automatically. Nevertheless, developers MUST use this operator sparingly. Consider also using the any and unknown types, which can achieve the same effect. Wherever you disable the type checker, it becomes your responsibility to type check at runtime instead. You SHOULD throw meaningful exceptions wherever your assumptions about the non-nullness of expressions turn out to be wrong.

Note

Non-null assertions can also be disabled globally by switching off strictNullChecks in tsconfig.json.

Definite assignment assertions

Since TypeScript 2.7, an exclamation mark ! may be placed immediately after declarations of variables and properties. This is the definite assignment assertion operator and it tells the type checker to assume that the variable is assigned, even if the type checker cannot automatically detect the assignment.

let x: number
x + x // Error: Variable 'x' is used before being assigned.
let x!: number
x + x // No error.

The same effect can be achieved with the non-null assertion operator, but definite assignment assertions SHOULD be preferred where all instances of a variable or property will be assigned:

let x: number
x! + x! // No error.

Handling "possibly undefined" values

A common error from the TypeScript compiler is:

TS2322: Type 'string | undefined' is not assignable to type 'string'.
Type 'undefined' is not assignable to type 'string'.

This happens whenever a value is typed as a certain type or undefined, but is passed to a component that expects the type and is not programmed to handle undefined.

function validateToken(token: string) {
  return token
}

const token = 'abc' as string | undefined

validateToken(token)

The same error occurs when accessing optional properties of an interface:

interface ProductInterface {
  id: string
  name?: string
}

class ProductModel implements ProductInterface {
  public id = ''
  public name

  constructor(id: string, name?: string) {
    this.id = id
    this.name = name || undefined
  }
}

const product = new ProductModel('1')
product.name // Error
product?.name // No error

There are several ways to keep the type checker happy; none is universally right or wrong, and the options suit different use cases.

The easiest solution is a conditional statement wherever you use a possibly-undefined value. Prefer to be as specific as possible:

if (typeof token === 'string') {
  validateToken(token)
}

Another option is the logical OR operator to pass a fallback value:

validateToken(token || 'default-token')

The nullish coalescing operator ?? is stricter: it checks only for undefined and null, while || checks for all falsy values (false, 0, NaN, empty strings):

validateToken(token ?? 'default-token')

Alternatively, use the as keyword to assert that the value will be of the declared type at this point in the runtime:

validateToken(token as string)

Or use the non-null assertion operator ! to tell the type checker that the value will not be undefined or null. Again, prefer to declare the type you do expect rather than the ones you don’t:

validateToken(token!)

Declaration files

TypeScript applications commonly consume untyped JavaScript libraries. To improve static type-checking coverage, the untyped APIs of JavaScript libraries can be defined in separate type declaration files (.d.ts), so that where a TypeScript application imports a JavaScript module the type checker can gain knowledge of its API and types.

A public repository of thousands of type declaration files exists for popular open source JavaScript libraries. However, these are maintained separately from the libraries themselves, and research has shown that mismatches between type declarations and the libraries they describe are frequent – causing the type checker to reject correct programs and accept incorrect ones. For this reason, library authors SHOULD maintain TypeScript type declarations in parallel with development of the libraries themselves.

Where libraries are written in TypeScript, declaration files are generated automatically by the compiler. Where libraries are written in JavaScript, authors can either write .d.ts files manually or embed JSDoc/TSDoc-compatible API documentation within inline source comments, from which the TypeScript compiler can generate .d.ts declarations. See Comments.

Decorators

Decorators are a compact syntax for wrapping one piece of code in another – literally "decorating" it. The technique is functional composition, and a decorator is a higher-order function: a function that returns another function, called with the details of the item being decorated. Decorators first reached most JavaScript developers via TypeScript and Angular, and are on the standardization track for ECMAScript (usable today via transpilation).

A decorator function produces a new function that can be called in exactly the same way as the function it wraps, adding behavior such as logging:

function logDecorator (fn) {
  return function () {
    const result = fn.apply(this, arguments)
    console.log(result)
    return result
  }
}

const myFn = () => { /* ... */ }
const wrapped = logDecorator(myFn)

wrapped()

With decorator syntax, the @ prefix applies a decorator immediately before the item it decorates, and multiple decorators compose into a pipeline:

@log
@immutable
class Example {

  @time()
  fn () {
    // ...
  }

}

The main advantage of the syntax is that the same clean notation applies to class constructors, properties, and methods – composition that was previously much harder.

Class member decorators

Class member decorators are applied to a single member (property, method, getter, or setter). The decorator function is called with three parameters: target (the class), name (the member name), and descriptor (the property descriptor, as passed to Object.defineProperty). A @readonly decorator:

function readonly (target, name, descriptor) {
  descriptor.writable = false
  return descriptor
}

class Example {
  @readonly
  b () {
    // ...
  }
}

const e = new Example()
e.b = 2 // TypeError: Cannot assign to read only property 'b'

Class decorators

Class decorators are applied to an entire class definition. The decorator function receives the constructor being decorated. This is generally less useful than member decorators – anything done here can be done by wrapping the constructor with an ordinary function – and a class decorator decorates every instance of the class.

function logDecorator (Class) {
  return (...args) => {
    console.log(args)
    return new Class(...args)
  }
}

@log
class Example {
  constructor () {
    // ...
  }
}

Asynchronous programming

Asynchronous programming lets a JavaScript program make calls to external systems without blocking the rest of the program from executing. It is widely used for I/O, timers, HTTP requests, and event-driven UIs.

Asynchronous programming in JavaScript is not the same thing as concurrent programming. JavaScript runs on a single-threaded event loop: asynchronous operations are queued and their callbacks run when the main thread is free, but only one piece of JavaScript ever runs at a time. (True parallelism requires worker threads or separate processes.)

Callbacks

For a long time, callbacks were the only way to express asynchronous operations, leading to deeply nested control flow known as "callback hell". With promises and async/await, callbacks SHOULD be avoided for new code.

Where a callback API must still be consumed, follow the Node.js error-first convention: the callback’s first argument is the error (or null on success), and subsequent arguments are the result. Never ignore the error argument.

Promises

A promise is an object representing the eventual result of an asynchronous operation. It is always in one of three states:

  • Pending – the operation has not completed; the promise holds no value (the initial state).
  • Fulfilled – the operation succeeded; the promise holds a result value.
  • Rejected – the operation failed; the promise holds an error value.

A promise is settled once it is either fulfilled or rejected; subsequent calls to resolve or reject have no effect. then registers fulfillment and rejection reactions and returns a new, dependent promise, which enables chaining:

const p = new Promise((resolve, reject) => {
  resolve(42)
})

const p2 = p.then((value) => {
  return value + 42
})

Promises avoid the pitfalls of event-driven code: reactions are scheduled even when attached after a promise has settled (no "lost events"), errors propagate along the chain, and chaining keeps code flat rather than nested.

async/await

async/await provides a cleaner syntax over promises. An async function always returns a promise, and await pauses execution until a promise settles, reading its fulfilled value or throwing its rejection:

async function loadConfig () {
  const raw = await fetch('/config.json')
  return raw.json()
}

Errors propagate naturally: a rejected awaited promise throws, so try/catch works as expected. In ES modules, top-level await is supported at the module scope (in CommonJS it must be wrapped in an async block – see Modules).

Combining promises

  • Promise.all. Resolves with an array of results once all input promises fulfill, or rejects as soon as any one rejects.
  • Promise.allSettled. Resolves once all input promises have settled, returning each as { status, value } or { status, reason }; it never rejects. Use this when you want every result regardless of individual failures.
  • Promise.race. Settles the same way as the first input promise to settle.
  • Promise.any. Resolves with the first input promise to fulfill, or rejects if all reject.
const [users, config] = await Promise.all([
  fetchUsers(),
  fetchConfig()
])

Async functions within loops

async/await MUST NOT be used inside higher-order functions such as forEach(), which do not await the callback – the enclosing function will return before the asynchronous work completes:

// Wrong: `printFiles` returns before the files are read.
async function printFiles () {
  const files = await getFilePaths()
  files.forEach(async (file) => {
    const contents = await fs.readFile(file, 'utf8')
    console.log(contents)
  })
}

To run the iterations in sequence (preserving order), use a for…​of loop, where await works as expected:

async function printFiles () {
  const files = await getFilePaths()
  for (const file of files) {
    const contents = await fs.readFile(file, 'utf8')
    console.log(contents)
  }
}

To run them in parallel and wait for all to finish, map to Promise.all:

async function printFiles () {
  const files = await getFilePaths()
  await Promise.all(files.map(async (file) => {
    const contents = await fs.readFile(file, 'utf8')
    console.log(contents)
  }))
}

Async iterators

If you want to handle data as it becomes available when individual promises in a collection resolve – rather than waiting for all of them – use async iterators. The for await…​of construct is the easiest pattern for iterating through the results of a collection of promises; forEach cannot be used (you cannot await it).

const g = new Glob('**/foo', {})

for await (const file of g) {
  console.log('found a foo file:', file)
}

This reads results in sequence. To read them in parallel, combine Promise.all with Array.prototype.map mapping to an async callback (as above).

Promise constructors

Use of Promise constructors (i.e. new Promise()) is considered an anti-pattern: it is almost always unnecessary, and wrapping existing promises in a constructor reintroduces the error-handling problems that promises were designed to solve. Prefer async/await, or chain existing promises directly.

Asynchronous constructors

A class constructor cannot be async, and await cannot be used to delay the return of a new instance. If construction needs to run asynchronous work, store the promise from an immediately-invoked async function expression and expose an asynchronous initializer or asynchronous methods that await it:

class ExampleClass {
  #dependency_promise

  constructor () {
    this.#dependency_promise = (async () => {
      // return a promise that resolves to the dependency
    })()
  }

  async otherMethod () {
    const dependency = await this.#dependency_promise
    // ...
  }
}

const inst = new ExampleClass()
await inst.otherMethod()

Making the consuming methods asynchronous is preferred over an initialized property that callers must remember to await, because the external API is cleaner and the instance is always in a valid state when its methods run. If the asynchronous dependency is needed by only one method, move the call out of the constructor into that method.

Functional programming

While JavaScript programs will typically be structured around object-oriented design patterns, at the lower level of data structures and algorithms a terse functional programming (FP) style is often favoured.

Some programming languages are specifically designed to facilitate functional programming. Examples include Clojure and Haskell. Functional programming languages emphasize standalone functions and closures operating on well-defined data structures, rather than classes or the idea of methods belonging to particular data objects.

While ECMAScript does have some characteristics of functional programming languages, it is better described as a multi-paradigm language. ECMAScript imposes few constraints on how programmers write their code, thereby supporting many different coding styles. This means a JavaScript program can be written in a mix of styles: functional, object-oriented, imperative, etc.

The trade-off is that programmers must take responsibility for enforcing coding conventions on their programs, since the language does not do this for them. Frameworks and libraries can help with this. There are a number of general purpose utility libraries, like Lodash FP, Ramda and 7urtle, that make it much easier to apply functional programming design patterns in JavaScript and TypeScript code.

What is functional programming?

In practical terms, functional programming means using design patterns like pure functions, currying, and immutable data structures. These patterns help produce code that is more declarative in style.

On a more theoretical level, functional programming is an approach to the design of algorithms and data structures that involves decomposing each problem into a pipeline of small, reusable and changeable functions, each of which is responsible for processing a well-defined dataset. The output of one function is passed as input to the next function, and so on along a pipeline, until a final result is produced. No data or external state is mutated during the procedure.

Advantages of functional programming

Functional programming patterns have a number of advantages.

Reusability

There is more opportunity to reuse existing code when a program is composed of small, discrete functions.

Changeability

Functional programs are easy to modify and extend.

When a program’s behavior is composed from pipelines of functions, to change behavior you need only to reconfigure the pipelines with different sequences of functions.

Predictability

It is generally easier to reason about functional programs, and to keep strong control over program flow, because there are fewer moving parts. For example, if you pass an object to a function, you know that the object will not be changed, and that there will be no other side effects – no surprises!

Testability

The predictable nature of functional programs makes them easier to control, change, and debug. And also to test. It is easier to write automated tests for predictable units of code. When testing functional units, there is no global state that needs to be mocked, and there are no side effects that need to be verified.

Performance

Functional programs tend to be easier to optimize for performance – for example, by taking advantage of the multiple cores of modern CPUs – and therefore easier to scale.

The output from functional components can be cached, because calls with the same inputs will always yield the same output. Techniques like memoization can be used for this purpose. This is when input to a function is mapped to the expected output, thus skipping repeated, expensive computations.

There are also more opportunities to implement concurrent processing. If a function is known to not mutate state, then you can have confidence that the function can be run in parallel (because there’s no risk it will change anything in memory that is used by other components of the system).

Reactive programs (e.g. data-driven UIs) also benefit from being written in a functional style. That’s because it is easier to detect mutations when you’re working with immutable objects. In JavaScript, val1 === val2 compares object and array references, not contents, so two distinct instances are never equal even if their contents match. This is what makes immutable data structures useful for change detection: a value is only ever replaced, never mutated in place, so a reference-equality check (val1 === val2) reliably tells you whether something changed.

Disadvantages of functional programming

The benefits of functional programming come with some trade-offs.

Of particular concern is the potential for functional programs to have a high memory overhead. When data structures are immutable, there naturally tends to exist more objects in memory at any one time. Furthermore, functionally-written programs tend to use more functions and closures than equivalent imperative code, and all those extra functions also consume memory.

When you’re dealing with a large number of objects, in the tens-of-thousands or even hundreds-of-thousands, and when a large number of changes are being applied to all those objects, then memory management starts to become a real concern for programmers. More garbage collection may need to be handled manually, rather than relying on the JavaScript engine, and techniques such as structural sharing – where identical values are shared rather than duplicated – may be needed to reduce memory overhead.

Functional programming patterns

This section describes the prominent design patterns in functional programming, and how these patterns are supported by ECMAScript languages.

Declarative style

At the most fundamental level, a functional program is one that makes extensive use of functions to create a coding style that is more declarative than imperative. While imperative code is very explicit about how it works, a declarative programming style is more implicit about this.

For example, to solve the problem of squaring all the values in a numerical list, an imperative approach would result in a solution like this:

function squareAll(numbers) {
  let squared = []
  for (let i = 0; i < numbers.length; i++) {
    squared.push(numbers[i] * numbers[i])
  }
  return squared
}

/* Usage. */
squareAll([1, 2, 3, 4]) // [1, 4, 9, 16]

A more declarative solution would look something like this:

function squareAll(numbers) {
  return numbers.map(num => num * num)
}

/* Usage. */
squareAll([1, 2, 3, 4]) // [1, 4, 9, 16]

Functional programming, which is a declarative programming paradigm, abstracts logic behind functions. The function names describe what the logic does, hiding details of the implementation (how it works). But for a program to be described as having been written in a fully functional style – rather than a more general declarative style – additional design constraints need to be evident.

Functions as first-class citizens

This is a principle of functional programming that is supported by ECMAScript out-of-the-box. In ECMAScript, all functions are first-class citizens. This means functions are values, and they can be treated just like any other kind of value. Thus functions can be:

  • Assigned to variables.
  • Passed as arguments.
  • Returned from other functions.

In the following example, the sayHello function returns an anonymous function. The script executes the sayHello function and creates a reference to the returned anonymous function, assigning it to a variable named fn. Finally, the value referenced from fn – the anonymous function – is executed.

function sayHello () {
  return function () {
    return 'Hello World'
  }
}

const fn = sayHello()
console.log(fn()) // 'Hello World'

This idea of being able to treat a function like any other value is a powerful technique that has a lot of use cases in computer programming.

Higher-order functions

Higher-order functions are functions that take another function as an argument, or that return a new function, or both. So, instead of operating on primitive values such as integers or booleans, or on data structures, these functions go higher and operate on other functions.

/* The `greet` function is an example of a higher-order function. */
function greet (fn) {
  console.log(fn())
}

function sayHello () {
  return 'Hello World'
}

greet(sayHello) // 'Hello World'

ECMAScript’s own standard library has many, many higher-order functions. Examples include Array.prototype.map and setTimeout.

const numbers = [1, 2, 3]
console.log(numbers.map((number) => number * 2)) // [2, 4, 6]

setTimeout(() => console.log('Hello World'), 1000) // 'Hello World'

These are simple examples, but designing higher-order functions can really help to compose complex algorithms from lots of small, discrete data processing procedures.

Note

FP and arrow functions

JavaScript’s arrow functions are perfect for creating higher-order functions, and for writing code in a functional programming style more generally.

Besides their more concise syntax, arrow functions also implement lexical scoping. This means they inherit their this value from the surrounding context in which the functions are defined. And, unlike named and anonymous functions, arrow functions cannot have their scope changed by being rebound to another context using bind() or other workarounds. Rebinding a function after its definition could potentially change the behavior of a function, which flies against the principles of functional programming.

For this reason, wherever a named or anonymous function does not need to internally reference this, it is RECOMMENDED to refactor the function as an arrow function.

const greet = (fn) => {
  console.log(fn())
}

const sayHello = () => {
  return 'Hello World'
}

greet(sayHello) // 'Hello World'

Function composition

One of the key ideas in functional programming is to break down each problem into small and reusable functions, and then combine the functions to compose algorithms that solve complex real-world problems.

Here’s a simple problem to demonstrate the functional approach. Imagine the problem to be solved is to trim blank space from the beginning and end of a string, and then wrap the trimmed string in a <div> element. An imperative-style solution might look something like this:

let input = '  JavaScript  '
let output = '<div>' + input.trim() + '</div>'

To implement this solution in a functional style, the problem is first broken down into discrete steps, each of which will be handled by a specialist function. In this case, the steps are:

  • Trim a string.
  • Wrap a string in a <div>.

Write functions for each of these tasks:

const trim = (str) => str.trim()
const wrapInDiv = (str) => `<div>${str}</div>`

/* Usage. */
let input = '  JavaScript  '
let output = wrapInDiv(trim(input))

Notice the code has already improved. These two functions are reusable in other contexts, and they can each be tested independently of the other. It will also be easier to extend this algorithm. To do that, more functions can simply be added to the data processing pipeline. For example, to convert the input string to lower case:

const trim = (str) => str.trim()
const wrapInDiv = (str) => `<div>${str}</div>`
const toLowerCase = (str) => str.toLowerCase()

/* Usage. */
let input = '  JavaScript  '
let output = wrapInDiv(toLowerCase(trim(input)))

The technique of transforming data via a pipeline of functions is called function composition or function piping.

But one of the unfortunate things of writing code in this style – in JavaScript, anyway – is that the pipeline operations are read from right-to-left. In this case, the input string value is first trimmed, then converted to lower case, and then finally wrapped in a <div> element. But the functions are written out, from left-to-right, in the opposite order. The reading order is not intuitive.

The other problem is ending up with deeply nested function calls, wrapped in lots of parentheses. This is difficult to read. When this technique is used to solve much more complex problems, these trade-offs will start to have big implications for code readability and maintainability.

To solve these problems, utility libraries like Lodash FP provide functions called compose and pipe. The compose function creates a new function that is a composition of all the functions to be run in a pipeline.

import { compose, pipe } from 'lodash/fp'

const trim = (str) => str.trim()
const wrapInDiv = (str) => `<div>${str}</div>`
const toLowerCase = (str) => str.toLowerCase()

const transform = compose(wrapInDiv, toLowerCase, trim)

let input = '  JavaScript  '
let output = transform(input)

Readability is improved, but still the order of the operations is not ideal. To solve that problem, the compose function can be swapped for pipe. This does the same thing, but it accepts as arguments the functions in the order they are to be called.

const transform = pipe(trim, toLowerCase, wrapInDiv)

Note

The compose and pipe functions are examples of higher-order functions, because they work with other functions.

Currying

Currying is another functional programming pattern. This technique is named after Haskell Curry, a mathematician who was hugely influential in the development of early computer programming languages.

What if the string needs to be wrapped in a <span> instead of a <div>? Another function would be needed, like so:

const wrapInSpan = (str) => `<span>${str}</span>`

But now there are two functions that are nearly identical. Their internal logic is basically the same. This is duplication, which bloats the codebase unnecessarily.

const wrapInDiv = (str) => `<div>${str}</div>`
const wrapInSpan = (str) => `<span>${str}</span>`

The obvious solution would be to add a parameter to the original function:

const wrap = (str, el) => `<${el}>${str}</${el}>`

The problem is this function can no longer be used in pipelines. Function composition works best when all the functions in a pipeline are unary, i.e. accepting exactly one parameter.

const wrap = (str, el) => `<${el}>${str}</${el}>`
const transform = pipe(trim, toLowerCase, wrap) // Fails.

So another abstraction technique is needed that allows any kind of function – with multiple or variable arguments – to be used in function composition.

The solution is currying. This is a design pattern in which a function with multiple arguments is transformed to a function that has only one argument. Consider the following example.

const add = (a, b) => {
  return a + b;
}

/* Usage. */
add(1, 2) // 3

To "curry" this function, you would rewrite it like this:

const add = (a) => {
  return (b) => {
    return a + b
  }
}

/* Or, more concisely (but less clearly): */
const add = a => b => a + b

In their usage, curried functions are not that different to regular functions. They are only distinguished by the fact that multiple arguments are separated by parentheses instead of commas.

/* Usage: */
add(1)(2) // 3

But now you have more flexibility. You could apply only some of the arguments, and then apply the remainder at a later time. This is known as partial application.

/* Usage with partial application: */
const add1 = add(1)

/* `add1` is a new function with pre-defined arguments. */
add1(2) // 3

Apply this technique to the wrap function:

const wrap = el => str => `<${el}>${str}</${el}>`

Now wrap can be used within a pipeline. wrap itself is not put in the pipeline; instead it’s used to create a new single-argument function, and it is that new function that goes in the pipeline.

const transform = pipe(trim, toLowerCase, wrap('span'))

/* Usage. */
transform('  javascript  ') // '<span>javascript</span>'

Lodash’s curry function can be used to turn any regular function into a curried one. However, it is generally better practice to design functions to support currying by default. This means designing function signatures with the following constraints:

  • Fixed arity. Functions SHOULD accept a fixed number of arguments. If a function is called with too-few arguments, the function MUST either throw an Error instance or return a new function that accepts the remaining arguments. If the latter, the result is returned only once all the arguments have been inputted. (This behavior cannot be easily achieved with mixed arity functions, i.e. functions that accept a variable number of arguments or optional arguments.)
  • Data-last. The data object or collection being transformed SHOULD be the last argument that gets passed to the function before it executes its logic on the data.
  • Iterator-first. The initial arguments to a function SHOULD be the iterator functions that will transform the data that will, in the end, be passed to the function.

Example:

/* Without currying. */
function discount(price, discountValue) {
  return price + discountValue
}

/* With currying. */
function discount(discountValue) {
  return (price) => {
    return price + discountValue
  }
}

/* Usage example. */
const price = 100

const tenPercentDiscount = discount(0.1)
tenPercentDiscount(price) // 10

const twentyPercentDiscount = discount(0.2)
twentyPercentDiscount(price) // 20

Out-of-the-box, most ECMAScript, web and Node APIs do not support currying. Consider for example ECMAScript’s implementation of map. It cannot be curried because it is a method of an Array object rather than a standalone function. Even Lodash’s map function cannot be curried because it puts the data object as the first argument.

Unfortunately this means a lot of extra coding is often needed before function composition can be done in JavaScript. Native APIs, and even most userland libraries, don’t make this easy. But happily there are some great utility libraries dedicated to solving the problem of doing functional programming in JavaScript. Lodash’s FP variant and the excellent Ramda and 7urtle packages all provide extensive libraries of curried functions, including for map.

_.map(func, data)

Note

Currying supports what is known as tacit programming or point-free style or point-free composition. These terms refer to a programming paradigm where function definitions do not identify the arguments (or "points") they operate on. Instead, function definitions merely compose other functions, as in the second example below:

function example (x) {
  return baz(bar(foo(x)))
}

/* Point-free style – the `x` parameter is absent: */
const example = pipe(foo, bar, baz)

Pure functions

Pure functions are functions that do not depend on or alter state outside of their scope, or have any side effects of any kind. Pure functions must always return new values, even if that data represents the same underlying values and concepts as the function’s input. And pure functions must not even mutate their own input parameters, because if they did, the result of the function could change over time (if the input values are referenced from elsewhere and mutated by other parts of the program).

Because a pure function does not depend on or alter any external state, it should always return the same output given the same input. The following function is not a pure function. That’s because if you called this function multiple times, even with the same argument, you will get a different result each time.

const randomize = (num) => {
  return num * Math.random()
}

In contrast, the following is a pure function.

const double = (num) => {
  return num * 2
}

So, pure functions cannot use:

  • Random values.
  • The current datetime.
  • Global variables and global state (e.g. DOM, filesystem, DB, etc).

Nor can they:

  • Write stuff to disk.
  • Log output to the console.
  • Interact with any external system of any kind.

The design constraint of function purity has numerous advantages. For one thing, it makes testing much easier. A pure function should always return the same result for the same input – which is known as referential transparency. So, for testing purposes all you need to do is verify different combinations of arguments. You don’t need to mock anything or to verify any side effects.

In JavaScript, achieving function purity takes a bit of extra effort by application programmers, not least because in JavaScript objects are passed by reference. This means that pure functions will need to deeply clone any objects they receive as arguments, if the function either subsequently returns the object or mutates it in any way. Even this is not always possible, for example if the object is a native one such as document or window.

In practice, not all functions should, or can, be pure in JavaScript applications. There will always be some classes of functions – such as abstractions that manipulate the DOM – that cannot be pure. But otherwise this is a really good design pattern to try to follow as much as possible.

Immutability

A concept that goes hand-in-hand with pure functions is immutability. Immutability is the idea that once an object is created, it cannot be changed. Instead, any operation that would change the object will instead create a new object with the changes applied. The original object will remain unchanged.

The constraint of immutability is another thing that helps to improve the predictability of program logic. It is yet another constraint that helps to enforce the principle of "no side effects". In addition, code that works on immutable data structures is easier to test, and it can also be more reliably run on distributed and multi-threaded systems.

In JavaScript, primitive types are actually immutable by default. For example, if you convert a string to uppercase, you get a brand new string primitive value.

let name = 'Harry'
let new_name = name.toUpperCase()

But immutability is difficult to achieve in JavaScript because data structures are modelled as objects and arrays, and by default these are mutable in JavaScript.

const book = {}
book.title = 'Harry Potter'

Most ECMAScript and web data structures and APIs are not designed for immutability. For example, .pop() directly removes an item from the end of an array. In the functional paradigm, a pop operation would copy the array structure, removing the intended element only from the new clone.

This is why JavaScript is not a pure functional programming language. In bona fide functional languages you cannot mutate data – period! Happily, immutable methods are being incrementally added to the ECMAScript specification. For example, ES 2023 standardized Array.prototype methods like toReversed and with that make it easier to change arrays in an immutable fashion. But, in general, to enforce immutability in JavaScript applications, programmers need to take responsibility for this themselves.

To enable immutable objects, there are a couple of options. One option is to use the Object.assign() method. This method can copy the contents of one object to another object.

const person = { name: 'John', age: 30 }
const updated = Object.assign({}, person, {
  name: 'Peter'
})

updated // { name: 'Peter', age: 30 }

Another option is to use the spread operator.

const person = { name: 'John', age: 30 }
const updated = { ...person, name: 'Peter' }

updated // { name: 'Peter', age: 30 }

However, both of these methods will only do a shallow copy, so you have to be careful in your handling of nested objects and arrays. In the following example, modifying the address.city of a cloned object also modifies the original object, too. Because the spread operator does only a shallow copy, the object referenced via the address property is the same object in memory, referenced from both person and updated.

const person = {
  name: 'John',
  age: 30,
  address: {
    country: 'USA',
    city: 'Seattle'
  }
}
const updated = { ...person, name: 'Peter' }
updated.address.city = 'New York'

person // { name: 'John', age: 30, address: { country: 'USA', city: 'New York' } }

To solve this problem, you have to do a deep copy.

const person = {
  name: 'John',
  age: 30,
  address: {
    country: 'USA',
    city: 'Seattle'
  }
}
const updated = {
  ...person,
  name: 'Peter',
  address: {
    ...person.address,
    city: 'New York'
  }
}

person // { name: 'John', age: 30, address: { country: 'USA', city: 'Seattle' } }

Updating arrays is similarly problematic. Removing elements from an array in an immutable fashion is easy; Array.prototype.filter() can simply be used for that, which always returns a new Array instance. But when you want to add new elements to an existing array, without mutating the original object, you end up writing code like this:

/* To add a new value to the end of the array: */
const numbers = [1, 2, 3]
const added = [...numbers, 4]
/* To add a new value to the beginning of the array: */
const numbers = [1, 2, 3]
const added = [4, ...numbers]
/* To add a new value in a specific position the middle of the array: */
const numbers = [1, 2, 3]
const index = numbers.indexOf(2)
const added = [
  ...numbers.slice(0, index),
  4,
  ...numbers.slice(index)
]
/* It is easier if you want to replace all instances of a particular value: */
const numbers = [1, 2, 3]
const updated = numbers.map((n) => n === 2 ? 20 : n)

In summary, enforcing immutability of data using standard ECMAScript APIs and syntax is a rather clunky process. The more complex the data structures, the harder it is to enforce immutability.

So in practical terms, specialist userland libraries are needed that offer real immutable data structures. Examples of such libraries include Immutable.js, Immer, and Mori. These libraries offer alternative implementations of native JavaScript data structures such as Maps and Sets.

For example, ECMAScript’s native Map objects are mutable:

const map1 = new Map()
const map2 = map1.set('one', 1)
const map3 = map2.set('two', 2)

map1 // Map { "one": 1, "two": 2 }
map2 // Map { "one": 1, "two": 2 }
map3 // Map { "one": 1, "two": 2 }

Which is equivalent to:

const map = new Map()
  .set('one', 1)
  .set('two', 2)

map // Map { "one": 1, "two": 2 }

Using Immutable’s Map implementation instead gives you the same data structure but in an immutable form:

import { Map } from 'immutable'

const map1 = Map()
const map2 = map1.set('one', 1)
const map3 = map2.set('two', 2)

map1 // Map {}
map2 // Map { "one": 1 }
map3 // Map { "one": 1, "two": 2 }

Note

Functional-reactive programming (FRP)

Functional reactive programming (FRP) is a programming paradigm that combines functional programming and reactive programming. In FRP, program state is modeled as a series of immutable values over time. Functions are used to transform those values.

These techniques are particularly beneficial in asynchronous and event-driven programs. Reactive libraries, including React itself, are often designed around FRP principles.

Recursion

Finally, it is worth mentioning recursion. This is a technique where a function calls itself from within its own definition. The technique is often used as an alternative to using loops. It is a classic functional programming technique, because it allows for the construction of loops that do not require maintaining state in local variables.

Recursion is widely used to solve complex problems where you need to perform the same operation repeatedly with different parameters. The technique is most effective for solving problems involving iterative branching, such as fractal math, sorting, or traversing non-linear data structures.

It happens to be the case that the recursion pattern is widely used in JavaScript programs – even ones not generally following FP principles. The classic example of recursion is in a factorial function. This is a function that multiplies a number again and again by each preceding integer, all the way down to 1. So, the factorial of three is:

3 x 2 x 1 = 6

And the factorial of six is:

6 x 5 x 4 x 3 x 2 x 1 = 720

In an imperative style, a factorial function would be implemented using a for loop:

const factorial = (number) => {
  let result = 1
  let count

  for (count = number; count > 1; count--) {
    result *= count
  }

  return result
}

factorial(3) // 6
factorial(6) // 720

Using recursion:

const factorial = (number) => {
  if (number <= 0) { /* Terminal case */
    return 1
  } else {
    return (number * factorial(number - 1))
  }
}

factorial(3) // 6
factorial(6) // 720

Writing code this way allows the whole process to be described in a stateless way. It is also worth noting that the argument is tested before doing any calculations. Where functions call themselves recursively, those functions SHOULD exit early when they reach their terminal case. In this example, the terminal case is when the argument is zero. (A separate check could also be added for negative numbers, handling these differently, e.g. by throwing an error.)

Conclusion

There is much more to functional programming. For example, in Haskell, there is a concept called monads, which encapsulate side effects (such as I/O operations), allowing them to be chained together in a controlled way. But in the context of ECMAScript languages, the concepts described above are the most useful.

We SHOULD apply functional programming principles to our JavaScript/TypeScript code wherever practical. Doing so will often improve our code. The constraints of the functional style enforce principles such as single responsibility, separation of concerns, and even inversion of control. Units of code tend to be simpler, smaller, more reusable, and easier to read, test and optimize.

Nevertheless, JavaScript is more of an object-oriented language than a functional one. Enforcing an FP style everywhere would fight the intentions of the language. Even so, there will be many use cases when it will be beneficial to apply FP principles to the lower levels of a codebase, especially where data structures and algorithms, and reactive and event-driven procedures, are involved.

Object-oriented programming (OOP) is often thought of as the opponent to FP, but ideas from both paradigms can be successfully combined in multi-paradigm languages like JavaScript. Even partial application of functional programming patterns, such as immutable data structures, can have big impacts on the quality of code. Prudent use of functional patterns can augment the object-oriented patterns that tend to shape the architectural design of JavaScript applications.

In summary, FP should be applied to JavaScript in a pragmatic fashion to solve specific problems. There is no need to be purist in the approach to functional programming. If that were beneficial, a functional language would probably be a better choice (compiled to JavaScript if necessary).

Where it does make sense to write code in a strong FP style, utility libraries like Ramda and Immutable SHOULD be used. These libraries provide wrappers around many native ECMAScript functions and web APIs, for example rearranging arguments to make composition easier. There’s no need to reinvent the wheel!

Runtimes

This section covers best practices for targeting specific versions of Node, web browsers, and other JavaScript runtime environments.

Node

Node’s release strategy can be confusing, but in short: at any point in time Node has one "current" major release, one "active" LTS (long-term support) release, and at least two older LTS releases under "maintenance". The current release is always the odd-numbered major directly above the active LTS major, and the two maintenance releases are the even-numbered majors before it.

The "current" versions are short-lived: Node’s current release gets a major version bump every six months, and odd-numbered major releases (11, 13, 15, etc.) stop being supported as soon as they lose "current" status, just six months after release. The "current" and odd-numbered releases are often too volatile to make support worthwhile. Node’s own recommendation is that production applications should use only active LTS or maintenance LTS releases – the even-numbered releases.

Only the even-numbered releases (10, 12, 14, 16, 18, etc.) therefore need be a concern. These versions are actively maintained for two-and-a-half years from their initial release. LTS for each even-numbered major version begins at a specific patch release, not the major’s initial .0.0 release – consult that major’s changelog on the Node repository for the exact starting patch. Each major also receives a codename in Node’s own alphabetical convention (e.g. "Hydrogen", "Iron").

It is that initial LTS patch release of each major version that SHOULD be the baseline target for support. The engines field in package.json specifies which versions of Node a package or application is compatible with, expressed as the initial LTS patch release of each supported major:

{
  "engines": {
    "node": "^<maintenance-lts-1>.x.x || ^<maintenance-lts-2>.x.x || ^<active-lts>.x.x"
  }
}

It is common for open source JavaScript packages to adopt a "current + LTS" policy that includes the odd-numbered current release. Such a policy MUST be implemented only in special circumstances, where there is business justification for targeting a short-lived major release:

{
  "engines": {
    "node": "^<maintenance-lts-1>.x.x || ^<maintenance-lts-2>.x.x || ^<active-lts>.x.x || ^<current>.0.0"
  }
}

Dropping support for Node versions

Dropping support for a major version of Node MUST happen in a major version bump of a package, per semantic-versioning principles. To give the major versions of a package the longest possible lifespans, support for a major version of Node SHOULD NOT be dropped just because its maintenance has been discontinued. Once a major version of Node is supported, it SHOULD be kept as a target until there are practical reasons that support must stop (for example, CI systems drop support, preventing tests from running). The actual list of supported Node versions is therefore expected to grow over time:

{
  "engines": {
    "node": "^<lts-1>.x.x || ^<lts-2>.x.x || ^<lts-3>.x.x || ^<lts-4>.x.x || ^<lts-5>.x.x"
  }
}

Other system software requirements

The engines field SHOULD also be used to declare other system software on which the package depends, particularly in repository manifests to declare dependencies on development tools such as Yarn and NPM:

{
  "engines": {
    "node": "^14.16.0",
    "yarn": "^1.22.0"
  }
}

Node version constraints

The engines field uses the same ^ and ~ version constraints as dependencies. See Dependency management for general guidance on version number constraints.

Node built-in modules

Use the node: URL scheme to import Node built-in modules, to distinguish them from vendor and local modules:

import fs from 'node:fs/promises'

This syntax is available from Node v12.20.0 and v14.13.1, and in require() from Node v14.18.0 and v16.0.0.

Node’s standard library is organized into built-in modules. The principal ones are:

  • Globalsfilename, dirname, module, exports, process, and Buffer (in CommonJS; these are not global in ESM – use import.meta and the node: modules instead).
  • Console and timersconsole, setTimeout/clearTimeout, setInterval/clearInterval, setImmediate/clearImmediate.
  • Processprocess for argv, env, stdio, exit, signals, and metadata.
  • Events and streamsevents.EventEmitter, and the stream readable / writable / transform streams.
  • File system and pathfs and path.
  • Networkinghttp, https, net, url, querystring.
  • Utilitiesutil (including util.promisify), os, assert, child_process.

Where a module offers both callback and promise APIs, prefer the promise API – for example node:fs/promises over the callback node:fs, or util.promisify for legacy callback functions. Prefer streams for large or streaming I/O over reading entire buffers into memory.

Web

All major web browsers now implement the standard JavaScript module system via <script type="module">; the same ESM conventions apply on the web as on the server (see Modules). Where the cost of many separate module requests outweighs the benefit, modules MAY be bundled for delivery. A "cut-the-mustard" progressive-enhancement strategy MAY be used to load JavaScript only in browsers that meet baseline runtime requirements.

Host APIs and feature detection

ECMAScript itself provides no I/O – no networking, storage, or graphics. These facilities are provided by the host environment (a web browser, Node, a web worker, etc.), and the available host APIs vary between runtimes. Code that depends on host APIs MUST detect the specific feature it needs rather than assuming its presence.

Object detection over UA sniffing

Sniff for the specific feature you intend to use, not for a feature that correlates with it. The presence of one feature does not imply the presence or absence of another – browsers add, remove, and fix features independently. Do not, for example, assume that a browser exposing a particular DOM feature must also expose some other (especially nonstandard) feature.

Do not user-agent (UA) sniff to target current or future browser versions. If you must UA-sniff, use it only to target past versions of specific browsers, and always keep a default code path that runs in unknown and current browsers.

Do not create separate codepaths for different browsers when one of the paths works everywhere – browsers converge behavior, and branching risks breaking the site as that convergence happens.

Vendor prefixes

Vendor-prefixed features are provided for experimentation and are not meant for production: their behavior can change as the specification evolves, and the prefixed version is usually removed once the feature ships unprefixed. Avoid prefixed features except to target old, buggy versions of a browser.

Where you must support a prefixed version, always prefer the unprefixed version when available, and put the unprefixed declaration last so it wins:

.pretty-element {
  -vnd-make-it-pretty: sometimes; /* old browsers */
  make-it-pretty: sometimes;       /* current browsers */
}

Do not use the unprefixed version of a property or API until at least one browser supports it – the final syntax may differ from any prefixed version. When using cutting-edge features that are not universally implemented, test the fallback path in a browser that does not implement the feature.

Detecting DOM event support

The DOM does not specify a way to detect exactly which events a browser understands. The reliable solution is to use only DOM events known to be supported by all target browsers, rather than relying on imperfect detection hacks.

Universal JavaScript

Universal JavaScript (also called isomorphic JavaScript) is code that runs in any runtime environment – in web applications, the same code runs both client-side in browsers and server-side in Node. Sharing code between client and server reduces duplication of business logic and supports progressive rendering.

General-purpose utility libraries that do not depend on host-specific APIs SHOULD be written to be universal where practical, to maximize reuse. But do not take this too far: a universal component that branches on process.browser to use different implementations per runtime ships unnecessary code to each environment (e.g. Node’s crypto is large when bundled for the browser) and skews coverage reports. Beyond genuinely host-agnostic utilities, it is acceptable to write packages for a narrowly defined range of runtimes.

Working with globals in universal code can require convoluted logic across Node’s global, the browser’s window, and a worker’s self.

Date

The new Date() constructor always returns a Date instance, even for an invalid date-time string, so an instanceof Date check does not verify the value. Validate a date by checking its timestamp:

const date = new Date('an invalid date-time string')
if (isNaN(date.getTime())) {
  // Date is invalid.
}

Date.prototype.toISOString() returns a date in simplified extended ISO 8601 format (YYYY-MM-DDTHH:mm:ss.sssZ), always in UTC (the Z suffix). To produce a local-time string, account for the timezone offset:

const offset = new Date().getTimezoneOffset() * 60000
const localISO = new Date(Date.now() - offset).toISOString().slice(0, -1)

Date.prototype.toLocaleString() produces a language-sensitive representation. Historically its output was inconsistent across engines; the ECMAScript Internationalization API (ECMA-402) adds locales and options arguments (a BCP-47 tag and format options) for consistent output, though support is not yet universal. Where consistent formatting matters and ECMA-402 is not available, use a dedicated date-time library.

const d = new Date(Date.UTC(2019, 11, 1, 9, 0, 0))
d.toLocaleString('en-GB', { timeZone: 'UTC' })

Architecture and design

This section covers the structure and architecture of our JavaScript programs. The secret to building and maintaining large-scale programs is to compose them from many small autonomous components, designed and organized so that each can be developed, tested, and maintained – and perhaps compiled and deployed – with some degree of independence from the others. Structure becomes increasingly important as a program grows in size and complexity, with consequences for extensibility, maintainability, performance, robustness, and security.

Our Node.js applications are built using Domain-Driven Design (DDD).

API design

All APIs are facades to hidden complexity. The aim is to facilitate concise but expressive code in the consuming application.

Consistency

Aim for consistency in the internal APIs of an application or library: consistent naming and ordering of parameters, consistent return types, and consistent naming conventions for getters and setters.

Function overloading

Function overloading – where a function’s behavior is radically altered depending on the arguments passed – is best avoided. It is perhaps best illustrated by jQuery, where load(), toggle(), and the jQuery() constructor all do different things based on their input parameters. Overloaded functions are difficult to test (because of the large number of possible code paths) and usually increase the external complexity of a component rather than decrease it.

Domain-driven design

Domain-Driven Design organizes a program into four layers.

Interfaces

This layer holds everything that interacts with other systems – web services, web applications, and batch-processing frontends. It handles interpretation, validation, and translation of incoming data, and serialization of outgoing data (such as HTML or JSON across HTTP).

Application

The application layer drives the workflow of the application, matching the use cases at hand. Its operations are interface-independent and can be synchronous or message-driven. This layer is well suited for spanning transactions, high-level logging, and security. The application layer is thin in terms of domain logic – it merely coordinates domain layer objects to perform the actual work.

Domain

The domain layer is the heart of the software. There is one package per aggregate, and to each aggregate belong entities, value objects, domain events, a repository interface, and sometimes factories. The core of the business logic belongs here. The structure and naming of aggregates, classes, and methods in the domain layer should follow the ubiquitous language, so that you can explain to a domain expert how this part of the software works using the actual names of the source code.

Infrastructure

Alongside the three vertical layers, the infrastructure supports all of them, facilitating communication between them. In simple terms, the infrastructure consists of everything that exists independently of the application: external libraries, the database engine, the application server, the messaging backend, and so on. Code and configuration that glues the other layers to the infrastructure – the database schema, repository interface implementations, and mapping configuration – is also part of the infrastructure layer. It should be possible to completely stub out the infrastructure in unit and scenario tests and still use the domain layer (and possibly the application layer) to work out the core business problems.

Quality assurance

Testing

Automated tests are an indirect but highly effective way to improve JavaScript code. Test frameworks are, at their core, a convenient API for making assertions about the behavior of code; test cases are scripts that use that API to define the assertions to verify.

Our preference is Mocha plus Chai and Sinon, which together cover everything from small packages to large applications. Mocha is a test framework that structures test suites and test cases (describe, it); Chai is an assertion library; Sinon provides spies and stubs. Alternatives include Jest, Ava, and Jasmine. Other specialized tools include Nock for HTTP mocking, jsdom (with mocha-jsdom) for emulating a browser environment, and Selenium or Cypress for web automation.

Test cases live in a ./test/ directory. It is conventional for unit tests to use the .test.js suffix and behavior-driven tests to use the .spec.js suffix. All tests SHOULD be written with standard ECMAScript module notation. Add a test script to package.json:

{
  "scripts": {
    "test": "mocha --recursive \"./test/**/[^_]*.test.js\""
  }
}

A unit test for a Cat class:

// ./src/Cat.js
class Cat {
  constructor(name) {
    this.name = name || 'Kitty'
  }
  meet(target) {
    if (!target) throw new TypeError('Missing target')
    return this.name + ', meet ' + target
  }
}
export { Cat }
// ./test/Cat.test.js
import { expect } from 'chai'
import { Cat } from '../src/Cat'

describe('Cat', function () {
  describe('constructor', function () {
    it('should use Kitty as the default name', function () {
      const cat = new Cat()
      expect(cat.name).to.equal('Kitty')
    })
    it('should set the name if provided', function () {
      const cat = new Cat('Feral')
      expect(cat.name).to.equal('Feral')
    })
  })

  describe('meet()', function () {
    it('should throw a TypeError if no target parameter', function () {
      expect(function () {
        (new Cat()).meet()
      }).to.throw(TypeError)
    })
    it('should meet the target', function () {
      const meet = (new Cat('Paw')).meet('Meow')
      expect(meet).to.equal('Paw, meet Meow')
    })
  })
})

Testing best practices

The independence of test cases is a key principle of unit testing. Isolate the units under test as much as practically possible, so that the source of errors is easy to pinpoint when tests fail. Use the framework’s hooks to reset state between cases, and stub or mock dependencies of the unit under test where needed. Test both passing and failing conditions: if expected failing conditions are not tested, the code may erroneously pass in some circumstances.

Linting

A linter statically analyses source code without running it. A good linter does three things:

  • Catches programmer mistakes early, shortening the development cycle. Static analysis is not a replacement for runtime test coverage, but linters are good at catching certain categories of bug – for example, an infinite loop:
    for (let i = 0; i < 10; i--) {
      // This loop runs infinitely.
    }
  • Checks for best practices, flagging design anti-patterns that make code harder to maintain – for example, == and != instead of === and !==:
    // Bad.
    if (x == 42)
    
    // Good.
    if (x === 42)
  • Checks code style – the visual formatting and presentation of the code.

ESLint is the industry-standard tool for static analysis of JavaScript code. Low-level style concerns SHOULD be enforced through ESLint rather than by hand.

Disabling ESLint

From time to time you will need to disable ESLint for particular snippets – for example, where you use proprietary syntax or features from very recent proposals that ESLint does not yet support. Prefer to disable ESLint on a line-by-line basis. Where it makes sense to disable ESLint for a whole file, place the following block comment near the top of the file, before any code:

/* eslint-disable */

Errors

JavaScript provides seven error types in its standard library:

  • Error – the parent of all other built-in *Error classes; represents problems not described by the other types.
  • EvalError – historically thrown on misuse of eval(); now effectively a built-in custom error type you MAY throw for "evaluation errors".
  • RangeError – thrown when a number falls outside an expected range (e.g. Number.prototype.toFixed() with an out-of-range argument).
  • ReferenceError – thrown when a non-existent variable is accessed (e.g. a misspelled or out-of-scope name).
  • SyntaxError – thrown when the engine fails to interpret malformed code. Syntax errors are unique in that a program cannot recover from them.
  • TypeError – thrown when a value is not of the expected type; the most useful built-in type in userland, and the most appropriate to throw when a custom function receives an invalid parameter.
  • URIError – thrown by URI-handling built-ins like encodeURI() and decodeURI() on malformed input.

All error objects have two public properties: name (the name of the Error class) and message (the string passed to the constructor).

Custom error types

Custom error types extend the built-in Error class using normal prototypal inheritance. They MUST extend Error (or another Error subclass): plain objects thrown as errors behave inconsistently with built-ins and lack stack traces. Prefer the class syntax:

class StackOverflowError extends Error {
  constructor(message) {
    super(message)
    this.name = this.constructor.name
    if (typeof Error.captureStackTrace === 'function') {
      Error.captureStackTrace(this, this.constructor)
    } else {
      this.stack = (new Error(message)).stack
    }
  }
}

To avoid repeating this constructor boilerplate for every custom error, encapsulate it in an abstract error class and extend that:

class AbstractCustomError extends Error {
  constructor(message) {
    super(message)
    Object.defineProperty(this, 'name', {
      configurable: true,
      enumerable: false,
      value: this.constructor.name,
      writable: true
    })
    if (typeof Error.captureStackTrace === 'function') {
      Error.captureStackTrace(this, this.constructor)
    } else {
      this.stack = (new Error(message)).stack
    }
  }
}

class AssertionError extends AbstractCustomError {}
class DomainError extends AbstractCustomError {}
class ParseError extends AbstractCustomError {}
class TimeoutError extends AbstractCustomError {}

Throwing exceptions

There is no restriction on what may be thrown in JavaScript – throw 1, throw 'hello', throw {} are all valid. This is a quirky feature that SHOULD be ignored. Always throw new instances of the built-in error types or a custom error type that extends Error. Engines give Error instances special treatment (notably stack traces); anything else produces no stack trace.

throw new TypeError('Invalid or missing parameters')

Exceptions SHOULD be thrown whenever a custom component is not used as designed – for example, when a function receives a parameter of an unexpected type or out-of-range value. This is defensive programming that produces robust, debuggable programs.

Handling exceptions

Code that may throw under some circumstances MUST be wrapped in a try clause. The try clause must be followed by a catch clause, a finally clause, or both. catch receives the error instance and stops propagation up the call stack; finally runs regardless of whether an error was thrown or caught, and is useful for cleaning up resources.

try {
  // Attempt to execute this code.
} catch (error) {
  // Receives the Error instance.
} finally {
  // Always executed.
}

Handle different error types differently with the instanceof operator:

try {
  // ...
} catch (error) {
  if (error instanceof TypeError) {
    // Handle TypeError exceptions.
  } else if (error instanceof ReferenceError) {
    // Handle ReferenceError exceptions.
  } else {
    // Handle all other exception types.
  }
}

Dangerous features

Some legacy features of JavaScript are error-prone and insecure, and most are excluded by default in strict mode.

eval() executes a string as JavaScript code. It is slow and dangerous – especially when the code originates from an external source – and the problems it solves are almost always better solved another way (e.g. with callbacks or dynamic import()). eval() MUST NOT be used.

with extends the scope chain with the properties of an object, making it ambiguous whether a name refers to a variable or an object property. It slows down execution and obscures intent. with MUST NOT be used.

Performance

JavaScript is a garbage-collected language: memory is allocated to objects on creation and reclaimed when no references to them remain. Memory leaks occur when objects are not cleaned up by the garbage collector, and they were historically common in old browsers (notably Internet Explorer) because of separate garbage collectors for DOM nodes and JS objects that failed to detect circular references between the two.

Circular references – where two or more objects refer to each other – and closures (which can hide such references) remain the common mechanisms for leaks. Break circular references by explicitly setting objects to null when finished with them, and use closures carefully.

window.onload = function () {
  obj = document.getElementById('mydiv')
  document.getElementById('mydiv').customProperty = obj // circular reference
}

Premature optimisation

Do not over-optimise too early. Simple loops generally benchmark better than equivalent function-based patterns, but elegance and readability often matter more than micro-efficiency. The basic rule: do not worry about efficiency until a program is provably too slow, then find the slow parts and trade elegance for efficiency there. Code for humans first; optimise for computers only when necessary – but do not ignore performance altogether.

Polyfills

A polyfill is a JavaScript shim that replicates a modern, standard API in runtimes that do not support it natively. Polyfills are less necessary now that the ecosystem has matured, and every polyfill adds weight to an application. A polyfill SHOULD be used only where a modern feature is essential to the application and there is no other way to implement the desired behavior – and there MUST be a good business reason for that.

Logging

Avoid console.* calls in production code; they are appropriate only for debugging. Use a structured logging library for application logging so that logs can be filtered, leveled, and shipped to a log aggregator. Keep console.log statements out of committed code unless they serve a documented purpose.

Barrel files

A barrel file is a TypeScript/JavaScript design pattern used to simplify and centralize exports from a module or directory.

Instead of importing individual components or utilities from multiple files, you create a single file – often named index.ts – that re-exports everything from those files. This makes imports cleaner and easier to manage.

Example:

/utils
  ├── formatDate.ts
  ├── parseUrl.ts
  └── index.ts

Each utility file exports a function:

// formatDate.ts
export function formatDate(date: Date): string { ... }

// parseUrl.ts
export function parseUrl(url: string): object { ... }

The barrel file (index.ts) re-exports these exports:

export * from './formatDate';
export * from './parseUrl';

Now, instead of importing like this:

import { formatDate } from './utils/formatDate';
import { parseUrl } from './utils/parseUrl';

You can do this:

import { formatDate, parseUrl } from './utils';

References