style guide for modern JS
nicholaswmin
targets: Node LTS 24, latest Chrome, Safari 26.3 (macOS/iOS)
- Foundation
- Structure
- Naming
- Syntax
- Use minimal semis, parens, and braces
- No comments, unless functionally necessary
- Prefer
const; uselet; nevervar - Use strict equality
- Never inline statements with conditionals
- Prefer arrows & implicit return
- Avoid pointless exotic syntax
- Use comma operator only for commit expressions
- Consider iteration over repetition
- Keep lines ≤80; wrap by structure
- Program flow
- Functional Programming
- Object-oriented Programming
- Error handling
- Testing
- Documentation
- HTML/Markup
Principles for project setup and dependency management.
ES modules are the JavaScript standard.
- Avoid
.mjs; prefer.jswith"type": "module". - Use
node:*imports for built-ins.
// ✅ ES modules
import { users } from './data.js'
import data from './data.json' with { type: 'json' }
import config from './config.js'
export const validate = input => check(input)
export const run = input =>
transform(input, { users, data, config })
// ❌ CommonJS
const { users: usersCjs } = require('./data')
const dataCjs = require('./data.json')
const configCjs = require('./config')
const validateCjs = input => check(input)
const runCjs = input =>
transform(input, { users: usersCjs, data: dataCjs, config: configCjs })
module.exports = { validate: validateCjs, run: runCjs }Make your module type, entrypoints, and runtime constraints explicit from day 0.
Minimal publishable package.json:
{
"name": "@johndoe/foo",
"version": "1.0.0",
"description": "Does foo bar & maybe baz",
"type": "module",
"exports": "./index.js",
"files": ["index.js", "src/"],
"engines": { "node": ">=24" },
"scripts": { "test": "node --test \"**/*.test.js\"" },
"keywords": ["foo", "bar"],
"author": "John Doe <johnk@doe.dom>",
"license": "MIT"
}Use internal import aliases so your code reads like domain modules, not filesystem paths.
- Export from
src/<name>/index.js. - Import with
#<name>.
This is internal to the package; it does not change consumer imports.
Minimal setup:
{
"type": "module",
"imports": {
"#*": "./src/*/index.js"
}
}Project structure:
foo-bar/
├── package.json
├── index.js
├── test/ # integration tests
│ └── main.test.js
└── src/
├── bar/
│ ├── index.js
│ └── test/ # bar unit tests (optional)
│ └── main.test.js
└── baz/
├── index.js
└── test/ # baz unit tests (optional)
└── main.test.jsImport using pretty paths:
// index.js
import { foo } from '#bar'
import { baz } from '#baz'
// workEvery dependency is a liability. Prefer built-ins and writing small utilities.
- Avoid DIY for domains that are too complex (game engines, CRDTs, date/time math) or too critical (cryptography).
// ✅ Use built-ins
const unique = [...new Set(items)]
const sleep = ms =>
new Promise(resolve => setTimeout(resolve, ms))
const pick = (obj, keys) =>
Object.fromEntries(
keys
.filter(k => Object.hasOwn(obj, k))
.map(k => [k, obj[k]])
)Speculative options and fallbacks bloat code and make behavior harder to trust.
- Implement only what is necessary.
- Do not add code based on speculation about future needs.
Avoid:
- Fallbacks for cases like
xyzthat should not happen - "Legacy" modes that don't actually exist
- Ad-hoc options or features added "just in case"
- Premature optimizations for unmentioned problems
Exception: You have a known, explicit requirement about any of the above.
// ✅ Implements only the known requirement (export as CSV)
const exportData = records => {
// omitted for brevity...
return toCsv(records)
}
// ❌ Invented optionality
// Goal was to export CSV.
// Nobody asked for JSON support.
const exportData = (records, { format = 'csv' } = {}) => {
// omitted for brevity...
return format === 'json'
? JSON.stringify(records)
: toCsv(records)
}Principles for organizing code into readable, testable modules.
Separate utility, domain, and orchestration logic so changes stay local and testing stays straightforward.
- Utility: Generic non-domain helpers
- Domain: Domain-specific helpers
- Orchestration: Usually the
mainof the module or program
// ✅ Utility functions
const gzip = file => compress(file, 'gzip', { lvl: 1 })
const delay = ms =>
new Promise(resolve => setTimeout(resolve, ms))
const hasExt = ext => file =>
file.filename.endsWith(`.${ext}`)
// ✅ Domain functions
const upload = (file, n = 0) =>
s3.upload(file).then(result => result.error
? n < 3
? upload(file, n + 1)
: ignore(file)
: result
)
// ✅ Orchestration functions
const synchronize = async files => {
const timedout = delay(30000).then(() => ({ timeout: true }))
const eligible = files.filter(hasExt('png')).map(gzip)
const uploaded = Promise.all(eligible.map(upload))
.then(data => ({ data }))
const result = await Promise.race([uploaded, timedout])
return result.timeout
? ontimeout(eligible)
: normalize(result.data)
}Keep core logic pure and push side effects to the edges so behavior is easier to test.
- Prefer pure functions over side effects.
- Move side effects to boundaries.
- Extract dependencies for easier mocking.
// ✅ Testable - pure function
const discountAmount = (price, pct) => price * (pct / 100)
// ✅ Testable - dependency injection
const notify = (user, emailer) =>
emailer.send(user.email, 'Welcome!')
// ❌ Hard to test - side effects
const applyDiscount = (price, percentage) => {
const discount = price * (percentage / 100)
updateDatabase(discount)
sendEmail(discount)
return discount
}
// ❌ Hard to test - hard-coded dependency
const notifyHard = user =>
EmailService.send(user.email, 'Welcome!')Extract dependencies when the function:
- Reaches outside your process (network, filesystem)
- Depends on uncontrollable factors (timers, sensors)
- Is slow or awkward to test
- Needs different implementations in different contexts
However, every indirection adds complexity.
Extract only when the benefit outweighs the cost.
// ✅ Simple operations don't need extraction
const formatName = (first, last) => `${first} ${last}`
// ❌ Needless indirection
const formatNameDelegated = (first, last, formatter) =>
formatter.format(first, last)Chain or inline unless the intermediate clarifies complex logic. Some functions naturally orchestrate others; do not mangle business logic for brevity or make it verbose.
// ✅ No intermediates needed
const promote = users => users.map(review).filter(passed)
// ❌ Needless intermediates
const promoteStaged = users => {
const reviewed = users.map(review)
const eligible = reviewed.filter(passed)
return eligible
}Wrappers should accept the same arguments as the function they wrap.
Add behavior by prepending/appending, but forward the rest unchanged.
This keeps call sites stable as upstream APIs evolve.
const prefix = col(['yellow'], 'warn:')
// ✅ signature mirrors console.warn
const warn = (...args) =>
console.warn(prefix, ...args)
// ❌ brittle wrapper
const warnBrittle = message =>
console.warn(prefix, message)Flatten control flow so the happy path is obvious and edge cases bail out early.
- Prefer expressions for selectors and transforms.
- Use early returns only when you need statements.
- Guards are for bailouts, not defensive checks. Fix the caller instead of guarding against bad input.
- Exception: validate external input at boundaries.
// ❌ selector written as statements (cop-out)
const active = users => {
if (!users.length)
return []
return users.filter(u => u.active())
}
// ✅ expression-bodied selector
const active = users =>
users.filter(u => u.active())
// ✅ early return in a procedure
function onUpdate(fence) {
if (this.insulated) return
if (!this.intersects(fence)) return this.electrocute()
// ... sync logic
}
// ❌ nested conditions
function onUpdateNested(fence) {
if (!this.insulated) {
if (this.intersects(fence)) {
// ... sync logic
} else {
this.electrocute()
}
}
}Use vertical whitespace to make the phases of a procedure visible at a glance. This applies to functions, files, modules, and projects.
- Split functions into blocks separated by one empty line.
- Typical blocks: guards, declarations, body, return.
- Avoid blank lines just to preserve intermediates.
- Prefer chaining.
// ✅ Clear visual grouping
const notify = async user => {
if (user.notified())
return false
const opts = { retries: 3, timeout: 5000 }
for (const email of user.emails)
await notifier.send(email, opts)
if (user.active)
user.complete(new Date())
.unlock()
return repo.save(user, opts)
}
// ❌ Dense, unstructured
const notify = async user => {
if (user.notified())
return false
const opts = { retries: 3, timeout: 5000 }
for (const email of user.emails)
await notifier.send(email, opts)
if (user.active)
user.markSeen()
return repo.save(user, opts)
}Keep related code adjacent.
Group by purpose, not by discovery order.
- Group by aspect or responsibility.
- Order by importance, dependency, or flow.
- Use whitespace to separate distinct groups.
- Follow the dominant pattern of the language or file.
- Break the pattern only when dependencies require it.
Examples:
- CSS: theme, base, layout, components, states, responsive overrides
- HTML: metadata, content, navigation, scripts
- JavaScript: constants, helpers, transforms, effects, initialization
❌: Mixed concerns and awkward ordering.
<title>Foo</title>
<meta property="og:title" content="Foo">
<script src="/foo.js"></script>
<meta property="og:description" content="Lorem ipsum dolor sit amet.">
<script src="/bar.js"></script>
<meta name="description" content="Lorem ipsum dolor sit amet.">
<meta property="og:type" content="website">✅: Group by purpose and keep each group internally ordered.
<title>Foo</title>
<meta name="description" content="Lorem ipsum dolor sit amet.">
<meta property="og:title" content="Foo">
<meta property="og:description" content="Lorem ipsum dolor sit amet.">
<meta property="og:type" content="website">
<script src="/foo.js"></script>
<script src="/bar.js"></script>Sometimes one structure must span files or layers.
Keep each fragment understandable on its own.
- Keep each fragment self-contained.
- Keep related rules easy to find.
- Make the structure removable in one cut when possible.
❌: Related table rules scattered across unrelated selectors.
table { border-collapse: collapse; }
h1 { font-size: 2rem; }
td { padding: 0.5rem; }
body { margin: 0; }
th { background: #f5f5f5; }
tr:hover { background: #fafafa; }
p { line-height: 1.6; }
caption { font-weight: bold; }✅: Keep related rules in one cohesive block.
body { margin: 0; }
h1 { font-size: 2rem; }
p { line-height: 1.6; }
table {
border-collapse: collapse;
& caption { font-weight: bold; }
& th { background: #f5f5f5; }
& td { padding: 0.5rem; }
& tr:hover { background: #fafafa; }
}CSS nesting makes the grouping structural, not just visual.
Guidelines for clear, intention-revealing names.
Good names compress meaning; they should be short, specific, and match how people talk about the domain.
- Name by role, not by type.
- Choose single words when precise alternatives exist.
// ✅ Good
users
active
name
overdue
// ❌ Avoid
userList
isUserActive
firstName
lastName
isPaid // when overdue is clearerPrefer domain-relevant names.
If none exists, pick the clearest.
-
Avoid single-letter params.
-
Use domain nouns.
-
Shorten with scope.
-
Name by intent.
-
Drop type/structure noise.
Exception: inline callbacks may use single letters when context is clear.
// ✅ Single-letter OK in concise inline callbacks
tables.filter(t => t.active)
rows.map(r => r.id)
items.forEach(i => send(i))
// ❌ Single-letter in function definitions
const processAbbrev = (u, r) => u.id === r.ownerId
// ✅ Full names in function definitions
const process = (user, resource) =>
user.id === resource.ownerId// ❌ negated passive
invoice.hasNotBeenPaid
// ✅ domain term
invoice.overdue// ❌ forced domain jargon in a generic util
const chunk = (ledgerEntries, size) => {}
// ✅ clear generic when no domain term fits
const chunk = (items, size) => {}// ❌ single-letter params in named functions
const editable = (u, r) =>
u.role === 'admin' || u.id === r.ownerId
// ✅ domain nouns in named functions
const editable = (user, resource) =>
user.role === 'admin' || user.id === resource.ownerId// ❌ type/structure noise
const userArray = await repo.list()
// ✅ intent + context
const users = await repo.list()Prefer verbs that match the domain action; generic verb+noun names hide intent.
- Prefer a domain verb over a generic verb+noun.
- Use singular verbs for actions.
- Use plural nouns for collections.
// ❌ generic verb+noun names muddy the domain
student.addGrade(95)
lesson.addStudent(alice)
user.sendNotification(msg)
post.createComment(text)
// ✅ precise domain verbs clarify the domain
student.grade(95)
lesson.enroll(alice)
user.notify(msg)
post.comment(text)
// ✅ escape hatch: generic verbs on generic containers are fine
lesson.students.add(alice)Assume the surroundings provide the context.
// ✅ Context eliminates redundancy
const user = { name, email, age }
const { data, status } = response
// ❌ Redundant qualifiers
const userVerbose = { userName, userEmail, userAge }
const { responseData, responseStatus } = responseName variables to match the object keys they will populate.
This enables concise object literals via property shorthand.
It avoids redundant qualifiers like requestHeaders or payloadBody.
The name should anticipate its use.
// ✅ Name matches the destination property
const headers = { 'X-Custom': 'value' }
const body = JSON.stringify({ id: 1 })
fetch(url, { method: 'POST', headers, body })
// ❌ Poor naming prevents shorthand
const requestHeaders = { 'X-Custom': 'value' }
const payloadBody = JSON.stringify({ id: 1 })
fetch(url, {
method: 'POST',
headers: requestHeaders,
body: payloadBody,
})If values form a conceptual unit, group them in an object. Do not encode relationships via naming conventions.
- Use an object when values are meaningless alone (start/end, x/y, min/max), passed as a unit, or named.
- Use a flat binding when the value stands alone.
- Flat is fine when sources differ, hot path matters, or React state update ergonomics require it.
// ✅ object is the concept (a range)
const fps = { start: 1, end: 40 }
// ❌ relationship encoded in prefixes
const startFps = 1
const endFps = 40
// ❌ single value wrapped pointlessly
const fpsWrapped = { target: 60 }Use namespacing to reflect real sub-concepts, not arbitrary layers.
- Group related properties under logical objects.
- Avoid meaningless wrappers or technical groupings.
// ✅ Appropriate namespacing
const user = {
name: { first, last, full },
contact: { email, phone },
status: { active, verified },
}
const config = {
server: { port, host },
database: { url, timeout },
}
// ❌ Inappropriate namespacing
const userOvernamespaced = {
data: { value: 'John' },
info: { type: 'admin' },
props: { id: 123 },
}Conventions for minimal, readable code.
Minimal syntax, maximum signal.
Code should read itself.
// ✅ Minimal syntax
const name = 'Alice'
const double = x => x * 2
const greet = name => `Hello ${name}`
users.map(u => u.name)
if (subscribers.length)
throw new Error('Session is subscribed')
const user = { name, email, age }
// ❌ Unnecessary syntax
const doubleBlock = x => {
return x * 2
}
const userExplicit = { name: name, email: email }Braceless nesting works when each body is a single statement. Take it all the way — do not add braces mid-chain.
// ✅ braceless all the way — each body is one statement
if (user.active)
if (user.verified)
for (const email of user.emails)
notify(email)
// ❌ brace mid-chain breaks consistency
if (user.active) {
if (user.verified)
for (const email of user.emails)
notify(email)
}Other guidelines make this nesting rare in practice.
No semis is fine, but do not start a statement with
(, [, /, +, -, or `.
Prefer rewriting; if unavoidable, prefix the line with ;.
// ❌ ASI hazard: array literal becomes index access
doThing()
[1, 2, 3].forEach(run)
// ✅ guard with leading semicolon
doThing()
;[1, 2, 3].forEach(run)
// ❌ ASI hazard: IIFE parsed as call
setup()
(() => start())()
// ✅ guard with leading semicolon
setup()
;(() => start())()
// ❌ regex parsed as division
const input = read()
/^ok$/i.test(input)
// ✅ guard with leading semicolon
const input = read()
;/^ok$/i.test(input)Comments are a maintenance burden.
If your code needs explanation, rewrite the code.
- Prefer names and structure.
- Do not add remarks.
Exceptions:
- Comments that affect behavior (JSDoc/TSDoc, linter toggles)
- Instructional style guides and docs
- READMEs and templates
// ❌ Explaining bad code with a comment
const x = users.filter(active) // active users
// ✅ Explain via naming
const activeUsers = users.filter(u => u.active)Use const to communicate immutability;
it prevents accidental reassignment and simplifies refactors.
- Default to
const. - Use
letonly for reassignment. - Never use
var.
// ✅ const by default
const users = await repo.list()
const total = items.reduce(sum, 0)
// ✅ let only when it must change
let page = 1
page++
// ❌ var and needless let
var count = 0
let name = 'Alice'Generally, prefer not declaring variables at all.
Strict equality avoids coercion surprises;
use == null only when you explicitly mean nullish.
- Prefer
===and!==to avoid coercion. - Use
== nullonly when you mean nullish.
// ✅ strict equality
if (status === 'ok') return next()
// ✅ intentional nullish check
if (value == null) return defaultValue
// ❌ coercive equality
if (count == '0') returnThis rule applies when you are already in statement form. Selectors and transforms should stay expression-bodied.
Always break after the condition.
// ✅ always break
if (!user)
return
if (!foobar)
return barbaz()
if (authenticated && verified && !suspended)
redirect('/dashboard')
for (const item of cart.items)
calculateTotal(item.price, item.quantity, tax)
// ✅ ternaries are expressions, not statements
const price = member
? discount(base)
: base
// ❌ statement on same line as condition
if (!user) return
if (authenticated && verified && !suspended) redirect('/dashboard')Skip braces and return for single expressions.
// ✅ Implicit return
const double = x => x * 2
const getName = user => user.name
const sum = (a, b) => a + b
const makeUser = (name, age) => ({ name, age })
// ❌ Explicit return for single expressions
const doubleBlock = x => {
return x * 2
}
const getNameBlock = user => {
return user.name
}Avoid clever tricks that hide intent; readability beats novelty.
- Choose clarity over cleverness.
- Use features when they improve readability.
// ✅ Clear intent
const isEven = n => n % 2 === 0
// ❌ Clever but unclear
const isEvenBitwise = n => !(n & 1)Comma is allowed as a tight "do the thing, then return the commit" form.
Keep it boring.
Rules:
- Expression-bodied arrows only
- Max 2 expressions
- No nesting
- No "clever sequencing"
// ✅ allowed: mutate, then return the commit
export const incAge = user =>
(user.incAge(1), repo.save(user))
// ❌ banned: multi-step cleverness
export const run = x =>
(step1(x), step2(x), step3(x))Use a loop for easier extension.
- Avoid overuse, especially in tests.
- Loops need mental parsing.
// ✅ Dynamic generation
return Object.fromEntries(
['get', 'put', 'post', 'patch']
.map(method => [method, send])
)
// ❌ Manual enumeration
return { get: send, put: send, post: send, patch: send }Short lines are easier to scan; wrap by structure so the visual shape matches evaluation order. Keep lines short by default, but do not wrap early unless it improves readability.
- Treat 80 as a guideline, not a reason to wrap a readable line.
- If a line is still readable, it's fine to go a bit longer (≤100).
- Wrap early only for complexity (mixed precedence, long conditions, nested ternaries).
- Prefer a single readable line over a staircase of tiny lines.
- Keep short, clear chains inline when they stay readable.
- Break long chains into one link per line.
- When breaking an expression-bodied arrow chain, keep the receiver on the same line.
- When wrapping a call, keep the callee on the same line; wrap arguments (especially object literals).
- Pack object properties onto lines; do not default to one-per-line.
- Keep each property line ≤70 chars.
- When content spans multiple lines, balance their lengths; avoid a packed line followed by a stub.
- Use dot-leading continuation lines for chained calls.
// ❌ dense, mixed precedence
const ok = () => a() || (b() && c(10) > 5)
// ✅ wrapped by structure; keep group parens
const ok = () =>
a() ||
(b() && c(10) > 5)// ✅ readable chain can stay inline
api().get().map().filter().reduce() + extra()// Anti-pattern: broke the call, kept the argument dense
const runCjs = input =>
transform(input, { users: usersCjs, data: dataCjs, config: configCjs })
// Pattern: keep the call compact; wrap the object argument
const runCjs = input => transform(input, {
users: usersCjs,
data: dataCjs,
config: configCjs,
})// ✅ fits — single line
const foo = Bar.baz('qux', { quux: 'corge', quuz: 'grault' })
// ✅ break, single content line ≤70
const foo = Bar.baz('qux', {
quux: 'corge', quuz: 'grault', garply: 'waldo'
})
// ❌ packed first line, stub second
const foo = Bar.baz('qux', {
quux: 'corge', quuz: 'grault', garply: 'waldo',
fred: 'plugh'
})
// ✅ balanced across lines
const foo = Bar.baz('qux', {
quux: 'corge', quuz: 'grault',
garply: 'waldo', fred: 'plugh'
})// ✅ short chain is fine inline
const id = req.user?.id ?? 'anonymous'
const url = new URL(path, baseUrl).toString()// ✅ short chain is fine inline
const normalize = path => path
.replace(/\/$/, '')
.toLowerCase()
.trim()
// ❌ long chain kept inline
const slugify = text => text.trim().toLowerCase().replaceAll(' ', '-').replace(/[^a-z0-9-]/g, '').replaceAll('--', '-')
// ✅ long chain — keep receiver on the same line; one link per line
const slugify = text => text
.trim()
.toLowerCase()
.replaceAll(' ', '-')
.replace(/[^a-z0-9-]/g, '')
.replaceAll('--', '-')Indentation counts toward the 80-char limit.
Nested code wraps sooner:
// ✅ nested — wrap earlier due to indent
const utils = {
path: {
normalize: path =>
path
.replace(/\/$/, '')
.toLowerCase()
}
}Patterns for control flow, sequencing, and conditionals.
Keep control flow top-to-bottom.
Inside-out is hard.
Top-to-bottom is natural.
// ✅ top-to-bottom
export const users = {
create: raw =>
Promise.resolve(raw)
.then(User.validate)
.then(User.from)
.then(user => repo.save(user))
}
// ❌ inside-out
export const users = {
create: raw => repo.save(User.from(User.validate(raw)))
}Avoid intermediates; chain or return directly.
// ✅ inline
export const norm = s => s.trim().toLowerCase()
// ❌ needless intermediate
export const norm = s => {
const x = s.trim().toLowerCase()
return x
}Vertical chains over temporary names.
// ✅ vertical chain
export const slug = s => s
.trim()
.toLowerCase()
.replaceAll(' ', '-')
// ❌ temporary names
export const slug = s => {
const a = s.trim()
const b = a.toLowerCase()
const c = b.replaceAll(' ', '-')
return c
}Recompute to avoid braces and bindings. If it is not hot-path, prefer clarity over performance.
// ✅ pure recomputation, no bindings
export const userView = raw => ({
href: `/u/${raw.name.trim().toLowerCase()}`,
label: raw.name.trim().toLowerCase()
})
// ❌ intermediate just to avoid recomputation
export const userView = raw => {
const name = raw.name.trim().toLowerCase()
return { href: `/u/${name}`, label: name }
}If mutation exists, make it explicit and commit immediately.
- Prefer promise chains over
awaitfor straight pipelines. - Use
awaitfor branching,try/catch, or early bailouts.
// ✅ explicit mutation, immediate commit
export const users = {
incAge: user => (user.incAge(1), repo.save(user))
}
// ❌ external function hides the mutation
export const users = {
incAge: user => (incrementAge(user, 1), repo.save(user))
}New values are easier to debug than mutations. Entities with identity get classes; data gets transforms.
// ✅ immutable - data transforms
const add = (items, item) => [...items, item]
const totals = prices.map(p => p * 1.2)
const config = { ...defaults, port: 3000 }
// ❌ mutating
items.push(item)
defaults.port = 3000Sequential mutations produce intermediate states nobody inspects and nobody wants. Collapse them into one expression that builds the result whole.
// ❌ sequential mutations
.reduce((acc, o) => {
acc[o.region] ??= { count: 0, revenue: 0 }
acc[o.region].count++
acc[o.region].revenue += o.total
return acc
}, {})
// ✅ single operation
.reduce((acc, o) => ({
...acc,
[o.region]: {
count: (acc[o.region]?.count ?? 0) + 1,
revenue: (acc[o.region]?.revenue ?? 0) + o.total
}
}), {})
// ❌ sequential mutations
acc.push(name)
acc.push(email)
return acc
// ✅ single operation
[...acc, name, email]Prefer vertical chains so data flows top-to-bottom.
// ✅ Multi-line chaining
const process = data => data
.filter(x => x > 0)
.map(x => x * 2)
.reduce((a, b) => a + b)
// ❌ Nested function calls
const processNested = data =>
reduce(
map(
filter(data, x => x > 0),
x => x * 2
),
(a, b) => a + b
)Prefer expressions over statement blocks.
- Selectors and transforms should be expressions.
- Use early returns only when statements are required.
Expressions return values and compose.
Statements do not.
Guideline: collapse statements all the way where possible.
// ❌ statement
const greet = user => {
if (user.known) {
return user.welcome()
} else {
return user.introduce()
}
}
// ↓ remove else
const greet = user => {
if (!user.known) return user.introduce()
return user.welcome()
}
// ↓ collapse to expression
const greet = user => user.known
? user.welcome()
: user.introduce()Stop only when further collapsing hurts clarity.
- If every branch is a single expression, prefer a ternary.
- Return it directly.
// ❌ stopped early out of habit
const roleStatement = user => {
if (user.admin) return 'admin'
return 'user'
}
// ✅ return the expression directly
const role = user => user.admin ? 'admin' : 'user'
// ✅ simple fallback
const name = user.name || user.nicknamePrefer conditional expressions (?:, ||, &&, ??)
over statement blocks.
- Booleans → ternary.
- Strings → object map.
- Consolidate cascading guards with optional chaining.
// ✅ concise arrow returning the ternary
const greet = user => user.admin
? user.dashboard()
: user.known
? user.welcome()
: user.introduce()
// ✅ string key - object map
const handle = action => ({
create: () => save(data),
update: () => modify(data),
delete: () => remove(data),
})[action]?.() ?? cancel()
// ❌ stacked guards
if (!foo) return
if (!foo.bar) return
if (!foo.bar.baz) return doStuff()
// ✅ consolidated guard (checks nullish, not falsy)
if (foo?.bar?.baz == null) return
// ✅ positive guard when action is short
if (foo?.bar?.baz != null) doStuff()Repeated guards against one variable are noisy; collapse them into one membership check.
// ❌ repeated guards
if (value === 'foo') return false
if (value === 'bar') return false
if (value === 'baz') return false
// ✅ membership check
if (['foo', 'bar', 'baz'].includes(value))
return false
// ✅ multiline membership check for long lists
if ([
'foo', 'bar', 'baz', 'qux', 'quzz', 'dunnot',
'quux', 'corge', 'grault', 'garply',
'waldo', 'fred', 'plugh', 'xyzzy', 'thud'
].includes(value))
return falsePrefer declarative array methods over imperative loops.
prices.filter(discounted) tells you what.
A for loop makes you figure it out.
// ✅ functional
const valid = items.filter(active)
const names = items.map(pick('name'))
const total = prices.reduce(sum, 0)
// ❌ imperative
const activeItems = []
for (let i = 0; i < items.length; i++)
if (items[i].active)
activeItems.push(items[i])Sequential side effects need ordered execution.
for (const file of files)
await upload(file)Collect results in order without manual accumulation.
const responses = await Array.fromAsync(urls, url => fetch(url))Run independent operations in parallel.
const responses = await Promise.all(urls.map(url => fetch(url)))Choose the right combinator for your use case.
// ✅ concurrent, tolerate failures
const results = await Promise.allSettled(tasks)
const passed = results.filter(r => r.status === 'fulfilled')
// ✅ first to settle
const fastest = await Promise.race(requests)
// ✅ first to fulfill (ignores rejections)
const first = await Promise.any(requests)Techniques for data transformation pipelines.
When converting data, think pipelines, not procedures.
// ✅ Functional for transformations
const users = data
.filter(active)
.map(normalize)
.sort(by.name)This section is about call-site clarity.
The goal is predicates that read like prose without creating a DSL.
Currying removes plumbing so pipelines read as intent.
items.filter(overlaps(source)) reads as prose.
items.filter(i => overlaps(source, i)) reads as wiring.
Prefill the fixed argument, return a single-arg callback
that plugs directly into map/filter/reduce.
// ✅ curried — reads as prose
const overlaps = source => target =>
source.intersects(target)
hits.filter(overlaps(closed))
items.filter(missing('active'))
names.map(mask('*'))
// ❌ uncurried — wrapper noise
hits.filter(hit => overlaps(hit, closed))
items.filter(item => missing('active', item))
names.map(name => mask('*', name))Avoid when:
- the helper is single-use; inline it
- partial application makes argument order ambiguous
- it creates a mini DSL nobody asked for
A small generic set goes a long way:
export const missing = key =>
obj => obj?.[key] == null
export const has = key =>
obj => obj?.[key] != null
export const is = key =>
value => obj => obj?.[key] === value
export const not = pred =>
value => !pred(value)
users.filter(missing('active'))
users.filter(is('plan')('pro'))A bare predicate name loses meaning away from the import block.
For domain rules, keep the domain name at the call site.
This avoids namespacing-by-convention like isEligibleUser(...).
Use when:
- predicates appear far from imports (long modules)
- multiple domains share similar names (
isEligible,isActive) - you want a small, cohesive domain vocabulary
Avoid when:
- the domain module is a dependency hub (I/O, DB, globals)
- the predicate is purely mechanical (
has,missing,is)
Do this:
- export domain predicates as named exports
- import the domain as a namespace (
import * as User) - keep domain predicates pure and dependency-free
In user.js:
export const isEligible = user =>
!user.active && user.plan === 'pro'
export const canInvite = user =>
user.role === 'admin' || user.role === 'owner'At the call site:
import * as User from './user.js'
users
.filter(User.isEligible)
.filter(User.canInvite)If you refuse namespace imports, alias explicitly at the boundary:
import { isEligible as isEligibleUser } from './user.js'
users.filter(isEligibleUser)Utils makes sense in small, non-source scopes.
In tests, prototypes, or tiny helpers it is a frictionless hatch.
When the intent is to express a domain concept,
a generic namespace adds noise.
Avoid:
- mixing unrelated code under one meaningless name
- creating a dependency magnet that grows without bounds
- hiding domain meaning behind generic imports
Exceptions:
utils/folders intest/to reduce clutter- throwaway scripts or tiny apps
- one-file test suites
Do this:
- group code by a real concept (domain or mechanic)
- keep mechanic modules small and orthogonal
- keep domain vocabulary in domain namespaces
import * as User from './user.js'
import * as pred from './pred.js'
import * as str from './str.js'
users.filter(User.isEligible)
items.filter(pred.missing('active'))
names.map(str.trimLines)Avoid this:
import { isEligible, trimLines, missing } from './utils.js'When to use classes and how to design them well.
If it has identity and changes over time, make it a class.
Use classes when:
- there's a who: an entity with identity and lifecycle
- behavior mutates state intrinsic to something
- type hierarchy or
instanceofchecking is needed - host environment is already OOP (DOM, streams, web APIs)
- the domain maps clearly to it (entities, actors, state machines)
Prefer plain objects and functions when:
- data is just a record with no subject
- operations are stateless transformations
- no intrinsic state to protect
Model with classes when state and rules must stay consistent across operations.
- entities with identity and lifecycle
- actors that perform actions
- state machines with guarded transitions
- wrappers around stateful host APIs
// ✅ order with guarded transitions
class Order {
#state = 'draft' // draft | submitted | canceled
#completable() {
return this.#state === 'draft'
}
submit() {
if (!this.#completable())
return this
this.#state = 'submitted'
return this
}
cancel() {
if (!this.#completable())
return this
this.#state = 'canceled'
return this
}
get state() { return this.#state }
}Avoid classes for plain records and helpers; keep data as objects and keep utilities as functions.
Avoid when:
- records, payloads, config objects
- value types with no behavior
- bags of utility functions
- one-off procedures
// ❌ class for a plain record and a stateless helper
class Config {
constructor({ port, retries }) {
this.port = port
this.retries = retries
}
}
class ArrayUtils {
static sum(xs) {
return xs.reduce((a, b) => a + b, 0)
}
}
// ✅ object for data, function for logic
const config = { port: 3000, retries: 3 }
const sum = xs =>
xs.reduce((a, b) => a + b, 0)If a class exists, its behavior belongs on it.
Do not push behavior into external functions.
// ❌ Anemic
class UserAnemic {
constructor(name, email) {
this.name = name
this.email = email
}
}
const validateUser = user => user.email.includes('@')
const greetUser = user => `Hello, ${user.name}`
// ✅ Behavior on the class
class User {
constructor(name, email) {
this.name = name
this.email = email
}
get valid() { return this.email.includes('@') }
get greeting() { return `Hello, ${this.name}` }
}Anemic classes are structs with ceremony.
Move the behavior onto the class or drop the class.
Exception: frameworks that force anemic models (service layers, DI containers).
Work with the constraint, but keep your domain logic isolated.
Private fields (#field) encapsulate state idiomatically.
- Use when state must not leak to serialization, invariants must be enforced, or internals may change.
- Avoid when fields must serialize or ceremony outweighs the benefit (simple DTOs, short-lived objects).
class Counter {
#count = 0
increment() { this.#count++ }
get value() { return this.#count }
}
class Connection {
#socket
#retries = 0
constructor(url) {
this.#socket = new WebSocket(url)
}
get connected() { return this.#socket.readyState === 1 }
}Is it this.valid(this.status) or this.valid?
Is it calculate(amount) or this.calculated?
Static methods declare operations as stateless and context-free.
const DAY_MS = 86_400_000
class Invoice {
static overdue(date) {
return Date.now() > date + 30 * DAY_MS
}
static taxable(region) {
return !['DE', 'TX'].includes(region)
}
static fee(amount, rate) {
return amount * rate
}
}
// Usage
Invoice.overdue(invoice.dueDate)
invoices.filter(i => Invoice.overdue(i.dueDate))Use for validation, calculation, and transformation logic that applies across
all instances.
Getters provide property semantics for derived state.
const DAY_MS = 86_400_000
class Invoice {
get overdue() {
return Date.now() > this.dueDate + 30 * DAY_MS
}
get payable() {
return this.status === 'pending' && !this.disputed
}
get total() {
return this.subtotal + this.tax - this.discount
}
}
// Usage
return invoice.overdue ? sendReminder() : null
const amount = invoice.payable ? invoice.total : 0Name them as states or conditions, not actions.
Prefer precise domain terms over compound names:
// ❌ Verbose
get isActive() { return this.status === 'active' }
get hasPositive() { return this.balance > 0 }
get isOverLimit() { return this.usage > this.limit }
// ✅ Domain terms
get active() { return this.status === 'active' }
get funded() { return this.balance > 0 }
get exceeded() { return this.usage > this.limit }Instance methods mediate object collaboration.
They make coupling explicit through parameters.
class User {
manages(employee) {
return this.reports.includes(employee.id)
}
authored(document) {
return document.author.id === this.id
}
transfer(amount, recipient) {
this.balance -= amount
recipient.balance += amount
}
granted(permission) {
return this.permissions.includes(permission)
}
}class Document {
editable(user) {
return this.draft
? this.author.id === user.id
: user.admin
}
get locked() {
return this.archived || this.published
}
}Passing this.property to your own methods breaks encapsulation.
// ❌
class Order {
validate(status) {
return status === 'confirmed'
}
process() {
if (this.validate(this.status)) this.ship()
}
}
// ✅
class Order {
get confirmed() {
return this.status === 'confirmed'
}
process() {
return this.confirmed ? this.ship() : null
}
}That logic belongs in a getter or should be static.
Static factories create instances with named construction paths.
class Transaction {
static deposit(account, amount) {
return new Transaction({
type: 'deposit',
account: account.id,
amount
})
}
static between(sender, receiver, amount) {
return new Transaction({
from: sender.id,
to: receiver.id,
amount
})
}
}Clearer than multi-shape constructors.
JSON.parse(JSON.stringify(instance)) loses prototype, methods, and private
state.
Wire payloads are objects.
Positional constructors break revival.
// ❌ Positional
class EnginePositional {
constructor(hp) {
this.hp = hp
}
}
// ✅ Object parameter with defaults
class Engine {
constructor({ hp, rpm } = {}) {
this.hp = hp
this.rpm = rpm
}
}Adding fields later does not break call sites.
Reviver runs bottom-up.
Nested objects may already be instances.
// ❌ Always re-wraps revived children
class CarRewrap {
constructor({ engine } = {}) {
this.engine = new Engine(engine)
}
}
// ✅ Accept instances or plain objects
class Car {
constructor({ engine } = {}) {
this.engine =
engine instanceof Engine ? engine
: engine ? new Engine(engine)
: null
}
}Use explicit type keys and field lists.
constructor.name is unstable across bundlers.
const pick = (obj, keys) =>
Object.fromEntries(
keys
.filter(k => Object.hasOwn(obj, k))
.map(k => [k, obj[k]])
)
class Serializable {
static #reg = new Map()
static register(Ctor) {
if (!Ctor?.type) throw new TypeError('missing type')
Serializable.#reg.set(Ctor.type, Ctor)
}
toJSON() {
const { type, fields } = this.constructor
return { __type: type, ...pick(this, fields) }
}
static fromJSON({ __type, ...data }) {
return new this(data)
}
static reviver = (k, v) => {
if (!v || typeof v !== 'object' || !('__type' in v)) return v
const Ctor = Serializable.#reg.get(v.__type)
return Ctor ? Ctor.fromJSON(v) : v
}
}
class Engine extends Serializable {
static type = 'Engine@1'
static fields = ['hp']
constructor({ hp } = {}) {
super()
this.hp = hp
}
}
Serializable.register(Engine)Unknown types stay plain objects.
Version the type string for schema evolution.
Caution
Reviving from untrusted JSON is input validation.
Always validate the __type against an allowlist before instantiation.
Never use eval, new Function, or dynamic property access on
untrusted type strings.
JSON cannot represent cycles.
Handles and resources do not survive transport.
Serialize identifiers instead:
// ❌ Cycle-prone
class NodeCyclic extends Serializable {
static fields = ['next'] // Reference to another Node
}
// ✅ ID reference
class Node extends Serializable {
static type = 'Node@1'
static fields = ['id', 'nextId']
constructor({ id, nextId } = {}) {
super()
this.id = id
this.nextId = nextId
}
}Open/Closed Principle (OCP) keeps a core stable while variants grow.
Add new behavior by extending one abstraction, not by editing the runner.
Use when:
- new kinds are expected to multiply
- consumers must add their own variants
- branching in the core would keep growing
- the extension seam is an explicit requirement
Litmus test:
- add a feature by dropping in one folder
- remove a feature by deleting that folder
- touch no unrelated files
OCP is not the same as modularity.
A modular system imports modules.
An OCP seam lets the system discover extensions through one abstraction.
The core owns the workflow and the base type.
Extensions implement that type and nothing more.
Warning
OCP base classes must stay fully agnostic about concrete kinds.
- Never inspect subtype identity in shared code.
- Never add variant-specific behavior to the base class.
- If a new subtype requires editing the base class, the abstraction failed.
// lib/draw/index.js
export class Tool {
name = this.constructor.name
activate() {
throw new Error('Tool.activate must be implemented')
}
deactivate() {
throw new Error('Tool.deactivate must be implemented')
}
}The extension imports the abstraction.
The core discovers the extension.
The extension does not edit the core.
lib/
draw/
index.js
load-tools.js
tools/
eraser/
index.js
marker/
index.js
index.jsThe seam should hide the engine, not leak it.
Tool authors should implement behavior, not understand rendering internals.
If extension authors must know how the engine rasterizes, caches,
or dispatches work, the seam is too low-level.
Use a folder boundary when "add a kind" should mean "add a folder".
Start with a barrel file by default.
Reach for discovery only when zero-touch extension is a real requirement.
Default barrel:
// tools/index.js
export { default as Eraser } from './eraser/index.js'
export { default as Marker } from './marker/index.js'A loader is stricter.
Use it only when "add a kind" must mean "drop in one folder".
An empty tree is still valid.
Do this when using a loader:
- scan one tree
- load one entrypoint per kind
- instantiate through the shared abstraction
- keep loader concerns out of tool implementations
- return
[]when no kinds are present
// lib/draw/load-tools.js
import { readdir } from 'node:fs/promises'
import { resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const here = fileURLToPath(new URL('.', import.meta.url))
const toolsDir = resolve(here, '../../tools')
export const loadTools = async () => {
const entries = await readdir(toolsDir, { withFileTypes: true })
const paths = entries
.filter(entry => entry.isDirectory())
.map(entry => resolve(toolsDir, entry.name, 'index.js'))
if (!paths.length)
return []
return Promise.all(
paths.map(path =>
import(pathToFileURL(path).href).then(mod => new mod.default())
)
)
}A new kind should only implement the base API.
It should not edit the loader, the engine, or peer tools.
// tools/marker/index.js
import { Tool } from '#draw'
export default class Marker extends Tool {
name = 'marker'
activate() {
// start drawing
}
deactivate() {
// stop drawing
}
}OCP compliance is binary.
The seam is either closed to modification or it is not.
Extension ergonomics are separate.
- barrel file: one edit per feature, explicit, default
- filesystem discovery: zero touches, needs a loader
- inline branching in the core: not an extension seam
Start with a barrel file unless zero-touch extension is the requirement.
Use auto-discovery when plug and prune matters more than explicit enumeration.
Auto-discovery improves plug-and-prune ergonomics,
but it gives up some static linking and compile-time checking.
Liskov Substitution Principle (LSP) keeps polymorphism honest.
If code works with the base contract, it must keep working with every variant
without special cases.
Use when:
- a workflow consumes rules behind one contract (
apply,area, etc.) - the workflow relies on invariants about inputs, outputs, and errors
Signatures are not enough.
Callers also depend on behavior.
Make explicit:
- invariants the workflow relies on
- which errors are part of normal operation
- what side effects are allowed at the seam
// PricingRule contract (example):
// - apply(totalCents, context) returns integer cents >= 0
// - for valid inputs, does not throw
// - must not mutate context
export const addShipping = cents => ({
apply: (totalCents, context) => totalCents + cents,
})A rule breaks substitutability when it demands extra requirements
the contract did not promise.
Avoid:
- requiring extra context fields not required by the contract
- rejecting values the contract accepts as valid
// ❌ demands context.couponCents — stronger precondition
export const applyCouponFromContext = () => ({
apply: (totalCents, context) => {
if (!Number.isFinite(context?.couponCents))
throw new TypeError('missing couponCents')
return Math.max(0, totalCents - context.couponCents)
},
})
// ✅ configure at construction; apply() stays within contract
export const applyCoupon = couponCents => ({
apply: (totalCents, context) =>
Math.max(0, totalCents - couponCents),
})A rule breaks substitutability when it returns something the workflow does
not expect.
Avoid:
- returning a different shape sometimes
- breaking output invariants downstream code assumes
// ❌ returns an object instead of a number
export const addShippingEnvelope = cents => ({
apply: (totalCents, context) => ({ totalCents: totalCents + cents }),
})
// ✅ returns the same type the contract promises
export const addShipping = cents => ({
apply: (totalCents, context) => totalCents + cents,
})Side effects are not always bad.
They must be predictable.
Avoid:
- hidden network or filesystem work
- hidden mutation of shared state
// ❌ hidden filesystem read inside apply()
import { readFileSync } from 'node:fs'
export const applyTaxFromDisk = () => ({
apply: (totalCents, context) => {
const taxRatePct = JSON.parse(
readFileSync('./tax.json', 'utf8')
).taxRatePct
const taxCents =
Math.round(totalCents * (taxRatePct / 100))
return totalCents + taxCents
},
})
// ✅ side effects at the boundary; rule stays pure
const taxRatePct = await loadTaxRatePct({ zip: order.zip })
const rules = [
applyTaxPct(taxRatePct),
]Inheritance is where LSP failures show up loudly.
If the base API cannot express a subtype invariant safely, the subtype ends
up fighting the API.
Example:
export class DeliveryWindow {
constructor(startDay, endDay) {
this.startDay = startDay
this.endDay = endDay
}
setStartDay(startDay) {
this.startDay = startDay
return this
}
setEndDay(endDay) {
this.endDay = endDay
return this
}
}
export class SameDayWindow extends DeliveryWindow {
constructor(day) {
super(day, day)
}
setStartDay(day) {
this.startDay = day
this.endDay = day
return this
}
setEndDay(day) {
this.startDay = day
this.endDay = day
return this
}
}
export const delayStartDay = (window, day) =>
window.setStartDay(day)Callers that rely on setStartDay changing only the start
will be surprised by SameDayWindow.
Write one shared contract test for the seam, and run it against every implementation.
- Treat the seam contract as enforceable, not aspirational.
- Use one shared expectation set that every rule must satisfy.
export const assertPricingRuleContract = (rule, context) => {
const snapshot = JSON.stringify(context)
const nextTotalCents =
rule.apply(context.subtotalCents, context)
if (!Number.isInteger(nextTotalCents) || nextTotalCents < 0)
throw new TypeError(
`Expected integer cents, got ${nextTotalCents}`
)
if (JSON.stringify(context) !== snapshot)
throw new Error('Rule mutated context')
}
for (const rule of rules)
assertPricingRuleContract(rule, context)Strategies for errors, validation, and input normalization.
Do not paper over broken call sites with internal guards; keep invariants real and back them with tests.
-
Trust internal invariants.
-
If you cannot, write tests.
-
Do not litter internal code with defensive guards.
-
Guards are fine for valid bailouts in procedures.
-
Do not use guards to hide broken call sites.
Exception: validate external data at boundaries.
// ✅ Trust internal functions
const process = data => data.filter(active).map(normalize)
// ❌ Defensive programming within internal functions
const processDefensive = data => {
if (!data || !Array.isArray(data)) return []
return data
.filter(i => i && active(i))
.map(i => (i ? normalize(i) : null))
.filter(Boolean)
}
// ❌ Defensive early returns
const formatNameDefensive = (first, last) => {
if (!first) return ''
if (!last) return ''
return `${first} ${last}`
}
// ✅ Trust the caller
const formatName = (first, last) => `${first} ${last}`Choose error types deliberately and keep messages short, precise, and consistent.
Pick an error class based on what went wrong, not where you are in the code.
- wrong type:
TypeError - out of bounds:
RangeError - everything else:
Errorwith a clear message (include relevant values)
if (typeof value !== 'number')
throw new TypeError(
`Expected number, got ${typeof value}`
)
if (value < 0 || value > 100)
throw new RangeError(`Value ${value} must be 0 - 100`)
if (elapsedMs > timeoutMs)
throw new Error(`Exceeded ${timeoutMs} ms timeout`)Be short, but never vague.
- Use the shortest template that stays actionable.
- Include concrete values when they matter.
throw new Error(`Exceeded ${timeoutMs} ms timeout`)
throw new Error(`Expected ISO date, got ${input}`)Define a tiny set of message formats so related errors read as a uniform system.
- missing required value:
missing <NAME> - expectation mismatch:
Expected <expected>, got <actual> - invalid transition:
Cannot <verb> a <state> <entity> - timeout/limit:
Exceeded <limit> ms timeout
throw new TypeError('missing API_KEY')
throw new Error(`Cannot submit a ${state} order`)
throw new Error(`Exceeded ${timeoutMs} ms timeout`)Use one domain term per state and stick to it.
- If your domain says "settled", do not also say "paid".
- Prefer
Invoice already settledoverInvoice has already been paid.
throw new Error('Invoice already settled')
throw new Error('Invoice has already been paid')Use one message template across similar operations so the system reads predictably.
// ✅ uniform template across operations
submit() {
if (this.#state !== 'draft')
throw new Error(`Cannot submit a ${this.#state} order`)
// ...
}
cancel() {
if (this.#state !== 'draft')
throw new Error(`Cannot cancel a ${this.#state} order`)
// ...
}
// ❌ inconsistent phrasing
submit() {
if (this.#state !== 'draft')
throw new Error(`Cannot submit a ${this.#state} order`)
// ...
}
cancel() {
if (this.#state !== 'draft')
throw new Error(`Transition blocked for ${this.#state} order`)
// ...
}Users add spaces.
Environment variables arrive as strings.
Clean everything at the borders.
// ✅ Environment variables
const port = parseInt(process.env.PORT || '3000', 10)
const env = (process.env.NODE_ENV || 'development')
.toLowerCase()
// ✅ Booleans
const enabled = /^(true|1|yes|on)$/i.test(
process.env.ENABLED || ''
)
// ✅ Arrays from CSV
const hosts = (process.env.HOSTS || '')
.split(',')
.map(s => s.trim())
.filter(Boolean)
// ✅ User input
const email = (input || '').trim().toLowerCase()Validation:
// ✅ Validate at boundaries
const handleRequest = req => {
if (!req.body?.userId)
throw new TypeError('missing userId')
if (typeof req.body.userId !== 'string')
throw new TypeError(
`Expected userId string, got ${typeof req.body.userId}`
)
return processUser(req.body.userId)
}
// ✅ Early validation
const createUser = data => {
if (!data.name) throw new TypeError('missing name')
if (!data.email) throw new TypeError('missing email')
return save(normalize(data))
}Guidelines for the Node.js built-in test runner.
Use node --test.
Zero dependencies.
// ✅ Built-in test runner
// Run with: node --test "**/*.test.js"
import { test } from 'node:test'Preload shared setup with --import for hooks that run across all test files.
Place global setup in test/utils/setup.js.
Note: --import preloads modules for side effects.
Use --test-global-setup for explicit setup/teardown exports.
// test/utils/setup.js
import { before, after } from 'node:test'
before(async () => {
// start server, connect db, etc.
})
after(async () => {
// cleanup resources
})node --test --import ./test/utils/setup.js "**/*.test.js"Or use --test-global-setup for explicit setup/teardown exports:
// test/utils/setup.js
export const globalSetup = async () => {
// runs once before all tests
}
export const globalTeardown = async () => {
// runs once after all tests
}node --test --test-global-setup=./test/utils/setup.js "**/*.test.js"Setup errors abort the run.
Teardown is skipped if setup fails.
Hanging tests are worse than failing tests.
--test-timeout=<ms>sets per-test timeout (default: Infinity).- Exit on unhandled errors; never swallow silently.
- Prefer short timeouts; long waits hide real problems.
- Use
timeout <secs> <cmd>when running tests in CI.
// test/utils/setup.js
import { before, after } from 'node:test'
process.on('unhandledRejection', err => {
console.error('Unhandled rejection:', err)
process.exit(1)
})
process.on('uncaughtException', err => {
console.error('Uncaught exception:', err)
process.exit(1)
})node --test --test-timeout=5000 "**/*.test.js"Keep the test command stable so adding tests never requires updating CI or scripts.
- Use one glob that covers co-located tests.
- Avoid narrow paths that miss new test files.
# ✅ catches everything
node --test "**/*.test.js"
# ❌ misses co-located tests
node --test test/unit/*.test.jspackage.json examples:
test: node --test "**/*.test.js"
test: node --test --import test/utils/setup.js "**/*.test.js"Tests tell a story: component → scenario → expectation.
Nest them that way.
// ✅ Hierarchical structure
test('#withdraw', async t => {
await t.test('amount within balance', async t => {
await t.test('disperses requested amount', async t => {
// assertion with await
})
})
await t.test('amount exceeds balance', async t => {
await t.test('throws appropriate error', async t => {
// assertion with await
})
})
})One test, one assertion, one failure reason.
// ✅ Granular tests
test('#withdraw', async t => {
t.beforeEach(t => (t.atm = new ATM(100)))
await t.test('returns updated balance', async t => {
t.assert.strictEqual(await t.atm.withdraw(30), 70)
})
})Attach test data directly to the context using
t.beforeEach(t => t.atm = new ATM(100)).
Avoid external variables or imports for fixtures.
// ✅ Context fixtures
test('#withdraw', async t => {
t.beforeEach(t => (t.atm = new ATM(100)))
await t.test('disperses amount', async t => {
await t.atm.withdraw(30)
})
})Everything is on t: t.assert, t.mock, t.beforeEach.
t.mock resets automatically between tests.
// ✅ Context utilities
test('#notify', async t => {
await t.test('sends email', t => {
const emailer = t.mock.fn()
notify(user, { send: emailer })
t.assert.strictEqual(emailer.mock.callCount(), 1)
})
})Assert only what matters for the behavior under test; avoid coupling tests to incidental structure.
- Do not test what you do not care about.
- Avoid asserting entire objects that will grow.
// ✅ Partial assertions
test('#user properties', async t => {
t.beforeEach(t => {
t.user = { name: 'Alice', role: 'admin', extra: 'ignore' }
})
await t.test('has correct details', t => {
t.assert.partialDeepStrictEqual(t.user, {
name: 'Alice',
role: 'admin',
})
})
})Match keywords that survive rephrasing.
Pick keywords unique to the test:
- ✓
/insufficient/- specific to this error - ✗
/balance/or/invalid/- too generic
// ✅ Flexible matching
test('#withdraw', async t => {
t.beforeEach(t => (t.atm = new ATM(100)))
await t.test('exceeds balance', async t => {
await t.assert.rejects(() => t.atm.withdraw(150), {
name: 'Error',
message: /insufficient/i,
})
})
await t.test('preserves balance', async t => {
await t.atm.withdraw(150).catch(() => null)
t.assert.strictEqual(t.atm.balance, 100)
})
})Complete test example:
test('#atm', async t => {
t.beforeEach(t => (t.atm = new ATM(100)))
await t.test('#withdraw', async t => {
await t.test('within balance', async t => {
await t.test('returns updated balance', async t => {
t.assert.strictEqual(await t.atm.withdraw(30), 70)
})
})
await t.test('exceeds balance', async t => {
await t.test('throws error', async t => {
await t.assert.rejects(() => t.atm.withdraw(150), {
name: 'Error',
message: /insufficient/i,
})
})
await t.test('preserves balance', async t => {
await t.atm.withdraw(150).catch(() => null)
t.assert.strictEqual(t.atm.balance, 100)
})
})
})
await t.test('#properties', async t => {
await t.test('has location and currency', t => {
t.assert.partialDeepStrictEqual(t.atm, {
location: 'Main Street',
currency: 'USD',
})
})
})
})technical documentation guidelines
Write docs that are scannable and low-noise.
- scannable at a glance
- get to the point fast
- strip noise and repetition
Example:
## Refunds
Refunds require an order id.
Refunds are idempotent.
Refunds create a ledger entry. Headings should read like an index.
Rules:
- Start with a single
#title naming the scope. - Keep headings 1–10 words.
- Prefer verbs (Use/Prefer/Avoid/Never) for rule sections.
- Use nouns for reference sections.
- Prefer simple patterns:
[Verb] [Subject],X instead of Y,If X, do Y. - Never place a heading directly after a heading.
Always include at least one non-heading context line after each heading.
Exception: reference sections with obvious content. - One empty line before and after each heading.
<!-- ❌ verbose heading, heading after heading, no context line -->
# Payments module
## How to process refunds in the system
## Errors
- This is a list with no context line for the section.
<!-- ✅ short heading, context line after each heading -->
# Payments
## Process refunds
Refunds require an order id and a reason code.
## Errors
- `RangeError` for invalid amounts.
- `TypeError` for invalid types.One sentence per line makes diffs cleaner and nudges you toward concise prose.
- Start each sentence on its own line for cleaner diffs.
- End each prose line with two spaces to force a hard line break.
- Keep sentences ≤20 words; active voice; neutral tone; no filler.
Lorem ipsum dolor sit amet.
Consectetur adipiscing elit.
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Wrap by meaning, not by character count, so paragraphs remain readable without relying on editor soft-wrap.
- Break at semantic boundaries (punctuation, clauses, logical units).
- Do not wrap purely to satisfy a character count.
- Do not rely on editor soft wrap.
- Aim for ~80 chars; treat ~90 chars as a hard max.
Rules:
- Break at spaces or punctuation.
- Never split words.
- Never split text inside parens or backticks.
<!-- ❌ breaks mid-clause -->
The `--import` flag loads global test hooks, but you still need to set
timeouts and propagate errors.
<!-- ✅ breaks at the clause boundary -->
The `--import` flag loads global test hooks,
but you still need to set timeouts and propagate errors.Use a small set of Markdown constructs and keep them consistent.
Links:
- Prefer reference-style links; keep URLs at the end of the document.
Code:
- Use language tags on fenced blocks.
- Use backticks inline for code, commands, paths, and filenames.
Lists:
- Bullets for enumeration.
- Numbered lists for stepped procedures.
Tables:
- Align columns.
Labels:
- Use
**Label:**pseudo-headers to make sections scannable. - One empty line before and after pseudo-headers.
- Use
**❌:**and**✅:**only in style guides.
**Install:**
```bash
npm test
```
See [Node.js docs][node].
[node]: https://nodejs.org/README structure for Node.js packages.
# [Project Name]
[![test][testb]][test]
[1-3 word description, e.g., "web server"]
```bash
npm i author/repo
```
## [Verb] [subject]
[Context line without repeating header]
**[Label]:**
```js
// code example
```
## [Verb] [subject]
[Context line without repeating header]
1. [First step]
2. [Second step]
1. [Nested substep]
2. [Nested substep]
3. [Third step]
```js
// code example for workflow
```
```js
// concise example
```
## Run tests
```bash
npm test
```
## License
[MIT][li-spdx]
[testb]: https://github.com/user/repo/actions/workflows/test.yml/badge.svg
[test]: https://github.com/user/repo/actions/workflows/test.yml
[li-spdx]: https://opensource.org/licenses/MIT
[link-ref]: https://example.comPattern-based guides with examples.
# [Style Guide Name]
[Short description of this guide]
## [Category]
[Context about this category]
### [Topic]
[One-line principle.]
**❌: [Anti-Pattern]**
[Why it's bad.]
```[lang]
// bad example
```
**✅: [Pattern]**
[When to use.]
```[lang]
// good example
```
**✅: [Pattern]**
[When to use.]
```[lang]
// good example
```Warning
Author constraint: Never invent or list features or requirements
unless explicitly requested in the prompt or spec.
Minimal HTML/CSS for simple pages.
Principles for small pages.
Prefer modern, native, classless, accessible defaults.
- Semantic: Use
main, headings, focus states, and good contrast. - Classless: Use element selectors; add classes only when needed.
- Fluid: Use
80ch,rem, and a ratio scale viapow(). - Native: Use
color-scheme,light-dark(), and system fonts. - Modern: Use nesting,
pow(),text-wrap, andlight-dark().
<main>
<h1>Orders</h1>
<section aria-labelledby="refunds">
<h2 id="refunds">Refunds</h2>
<p>Refunds require an order id.</p>
</section>
</main>Use headings for structure, not styling.
Rules:
- one
h1per page - do not skip levels (
h2→h3, noth2→h4) - headings should label sections, not decorate layouts
- keep heading text short and concrete
❌: Skips a level.
<h1>Orders</h1>
<h2>Refunds</h2>
<h4>API</h4>✅: Uses consecutive levels.
<h1>Orders</h1>
<h2>Refunds</h2>
<h3>API</h3>A minimal, classless HTML template with modern CSS.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="dolor sit amet.">
<title>Lorem Ipsum</title>
<link rel="icon" href="https://fav.farm/🪜"/>
<style>
:root {
color-scheme: light dark;
--ratio: 1.25;
--bg: light-dark(#ffffff, #0b0b0c);
--text: light-dark(#111111, #e6e6e6);
--accent: light-dark(#0b57d0, #8ab4f8);
--tertiary: light-dark(#7c3aed, #c4b5fd);
--font-ui: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Helvetica, Arial, sans-serif;
--font-prose: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font-prose);
font-size: 0.9rem;
line-height: 1.6;
a {
color: var(--accent);
&:hover { color: var(--tertiary); }
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
main { max-width: 80ch; margin: 2rem auto; padding: 0 1rem; }
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-ui);
font-weight: 500;
line-height: 1.2;
margin-bottom: .5em;
text-wrap: balance;
}
p { text-wrap: pretty; }
h1 { font-size: calc(1rem * pow(var(--ratio), 3)); margin-top: 2em; }
h2 { font-size: calc(1rem * pow(var(--ratio), 2)); margin-top: 1.75em; }
h3 { font-size: calc(1rem * var(--ratio)); margin-top: 1.5em; }
h4, h5, h6 { font-size: 1rem; margin-top: 1em; }
h1:first-child { margin-top: 0; }
}
</style>
</head>
<body>
<main>
<h1>Less, but better</h1>
<p>
Good design is <a href="#">as little design as possible</a>. Less, but
better—because it concentrates on the essential aspects, and the products
are not burdened with non-essentials.
</p>
<p>
Minimalism isn't the absence of detail—it's the absence of the unnecessary.
What remains must be considered exhaustively. Every margin, every weight,
every pixel earns its place or gets cut.
</p>
<blockquote>
<p>Good design is thorough down to the last detail.</p>
<cite>— <a href="https://www.vitsoe.com/gb/about/dieter-rams">Dieter Rams</a></cite>
</blockquote>
<h2>Clarity</h2>
<p>
Typography exists to honor content. A well-set page invites the reader in
and makes the act of reading effortless. The best interface is one you
don't notice.
</p>
<h3>Hierarchy</h3>
<p>
Scale creates rhythm. Consistent ratios between heading levels guide the
eye naturally through content, establishing importance without shouting.
</p>
</main>
</body>
</html>