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.
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:
falsenullundefinedNaN0''(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
constfor any binding that is not reassigned.constis the strongest signal of intent: the binding will not change. - Use
letwhere reassignment is genuinely needed – loop counters, value swaps, accumulators. Aletbinding MAY be reassigned but is still block-scoped. - Avoid
varentirely. It is function-scoped (not block-scoped), hoisted, and permits redeclaration, all of which are sources of bugs. Anyvarcan be replaced withconstorlet, and a remainingvaris 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 sExcept 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 promisedStringDescriptions 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 myPetCombine import() with typeof to discover the type of a value from a JavaScript module:
/**
* @type {typeof import('./accounts').userAccount }
*/
const x = require('./accounts').userAccountVariables
The data types held by variables are declared with @type:
/**
* @type {string}
*/
let sAnything 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]/giType 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}
*/
sizeClass 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 includeprocess,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 (thoughis retrievable viaObject.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 // trueTo 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
.jsextension indicates a standard ECMAScript module or script, while the non-standard.cjsextension indicates a non-standard CommonJS module. - All
.jsfiles 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
libdirectory (via which the package’s programmatic API is exported) or abindirectory (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,libencapsulates custom scripts for the automation of development, compilation, testing, release, and other maintenance activities, commonly executed by run-scripts in therundirectory.run. Run-scripts referenced from thescriptssection of the repository-levelpackage.jsonmanifest.src. Source code. The sub-directory tree SHOULD be organized to mirror how the distributed packages will be installed innode_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 errorThere 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
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) // 3To "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 + bIn 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
Errorinstance 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) // 20Out-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) 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) // 720Using recursion:
const factorial = (number) => {
if (number <= 0) { /* Terminal case */
return 1
} else {
return (number * factorial(number - 1))
}
}
factorial(3) // 6
factorial(6) // 720Writing 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:
- Globals –
filename,dirname,module,exports,process, andBuffer(in CommonJS; these are not global in ESM – useimport.metaand thenode:modules instead). - Console and timers –
console,setTimeout/clearTimeout,setInterval/clearInterval,setImmediate/clearImmediate. - Process –
processfor argv, env, stdio, exit, signals, and metadata. - Events and streams –
events.EventEmitter, and thestreamreadable / writable / transform streams. - File system and path –
fsandpath. - Networking –
http,https,net,url,querystring. - Utilities –
util(includingutil.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*Errorclasses; represents problems not described by the other types.EvalError– historically thrown on misuse ofeval(); 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 likeencodeURI()anddecodeURI()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
- Airbnb (n.d.). Airbnb JavaScript Style Guide. GitHub. — One of the most widely-cited JavaScript coding standards; see also the companion CSS-in-JavaScript Style Guide.
- Google (n.d.). Google JavaScript Style Guide. — Google’s coding standards for JavaScript; see also the pre-ES6 version.
- Waldron, R (n.d.). Idiomatic.js. GitHub. — A community set of best practices and coding conventions for JavaScript.
- Aboukhadijeh, F (n.d.). Standard JS. — An opinionated, semicolon-free JavaScript style with a linter and automatic code fixer.
- Geisendörfer, F (n.d.). Felix’s Node Style Guide. GitHub. — A Node.js coding style guide by Felix Geisendörfer.
- Sta. Cruz, R (n.d.). RSJS. — A "reasonable system for JavaScript structure" for non-SPA web applications.
- Adobe (n.d.). Adobe JavaScript Coding Standard. — In-house standard for Adobe Commerce and Magento.
- Platypi (n.d.). Platypi TypeScript Style Guide. GitHub. — A corporate style guide for JavaScript and TypeScript.
- McDermott, R (n.d.). clean-code-javascript. GitHub. — Clean-code concepts adapted for JavaScript; see the TypeScript adaptation.
- Goldberg, Y (n.d.). Node.js Best Practices. GitHub. — A popular, maintained community best-practices compendium for Node.js.
- DesLauriers, M (n.d.). Module Best Practices. GitHub. — Best practices for writing Node-style modules.
- Elliott, E (n.d.). Master the JavaScript Interview: What is Functional Programming?. Medium. — An overview of functional programming applied to JavaScript.
- Elliott, E (n.d.). The Two Pillars of JavaScript, Pt 2: Functional Programming. Medium. — On prototypal inheritance and functional programming as JavaScript’s two defining features.
- Lonsdorf, B (n.d.). Professor Frisby Introduces Composable Functional JavaScript. egghead. — A free video course introducing functional programming in JavaScript.
- Lonsdorf, B (n.d.). Professor Frisby’s Mostly Adequate Guide to Functional Programming in JavaScript. — A free online book on functional programming in JavaScript.
- Simpson, K (n.d.). Functional-Light JavaScript. GitHub. — A free online book on applying functional programming pragmatically in JavaScript.
- Salcescu, C (n.d.). Discover Functional JavaScript. Amazon. — A book on functional programming techniques in JavaScript.
- Johansen, C (n.d.). Pure JavaScript. Vimeo. — A video talk on functional programming in JavaScript.
- 7urtle (n.d.). JavaScript Functional Programming Advantages. — An article on the benefits of functional programming in JavaScript.
- Hewlett-Taylor, T (n.d.). A Light Introduction to Tacit Programming with JavaScript. dev.to. — An introduction to point-free style in JavaScript.
- Haverbeke, M (n.d.). Eloquent JavaScript: Functional Programming. — The functional programming chapter from the first edition of Eloquent JavaScript.
- Gechev, M (2013). Functional Programming with JavaScript. — A practical introduction to functional programming techniques in JavaScript.
- Oxley, T (n.d.). Functional JavaScript Workshop. GitHub. — A Node-based workshop for learning functional programming.
- Rinaldi, B (n.d.). Functional Reactive Programming in JavaScript. — Implementing FRP with libraries such as Bacon.js and RxJS.
- Iffland, D (2016). Virtual Panel: State of Reactive in JavaScript and Elm. InfoQ. — A panel discussion on the reactive landscape in JavaScript and Elm.
- Podwysocki, M (2010). Introduction to the Reactive Extensions to JavaScript (RxJS). — An early introduction to RxJS.
- Osmani, A (2012). How to Write Fast, Memory-Efficient JavaScript. Smashing Magazine. — Techniques for writing performant, memory-efficient JavaScript.
- Osmani, A (n.d.). JavaScript Start-Up Performance. Medium. — On parsing and compilation bottlenecks that affect web application start-up.
- Baker, G and Arvidsson, E (n.d.). Optimizing JavaScript Code. Google PageSpeed Insights. — Performance guidance from engineers on GMail and Chrome.
- Kikobeats (n.d.). MythBusters JS. — A JavaScript performance & readability handbook covering Array, Date, Number, Function, RegExp, Object, V8, and Workflow optimization tips; see the source repository.
- Husain, J (n.d.). Asynchronous Programming: The End of The Loop. egghead. — A free video course on asynchronous programming in JavaScript.
- Severien, T (n.d.). The Importance of Writing Code That Humans Can Read. SitePoint. — On writing human-readable code, with JavaScript examples.
- Ronacher, A (2022). Dependency Risk and Funding. — On the risks of third-party dependencies and the sustainability of the open-source ecosystem behind them.