Skip to content

Instantly share code, notes, and snippets.

@nicholaswmin
Last active March 9, 2026 21:36
Show Gist options
  • Select an option

  • Save nicholaswmin/3c251f53c8e12d5b150d7a8535553aa5 to your computer and use it in GitHub Desktop.

Select an option

Save nicholaswmin/3c251f53c8e12d5b150d7a8535553aa5 to your computer and use it in GitHub Desktop.
rolling styleguide

es.next

style guide for modern JS
nicholaswmin

targets: Node LTS 24, latest Chrome, Safari 26.3 (macOS/iOS)

Contents

Foundation

Principles for project setup and dependency management.

Prefer ESM; avoid CommonJS

ES modules are the JavaScript standard.

  • Avoid .mjs; prefer .js with "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 }

Always start with a package.json

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 catch-all imports for nicer imports

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.js

Import using pretty paths:

// index.js
import { foo } from '#bar'
import { baz } from '#baz'

// work

Avoid needless dependencies

Every 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]])
  )

Avoid invented fluff & fallbacks

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 xyz that 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)
}

Structure

Principles for organizing code into readable, testable modules.

Layer functions by purpose

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 main of 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)
}

Separate logic from side effects

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)

Skip needless intermediates

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
}

Preserve wrapped signatures

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)

Avoid nesting; use early returns

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()
    }
  }
}

Group steps with vertical whitespace

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)
}

Group by purpose

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>

Keep split parts self-contained

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.

Naming

Guidelines for clear, intention-revealing names.

Name concisely and precisely

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 clearer

Use short, contextual names

Prefer 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()

Use domain verbs; avoid verb+noun names

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)

Drop redundant qualifiers

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 } = response

Name for property shorthand

Name 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,
})

Avoid prefix grouping; use objects

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 for structure

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 },
}

Syntax

Conventions for minimal, readable code.

Use minimal semis, parens, and braces

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.

ASI hazards

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)

No comments, unless functionally necessary

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)

Prefer const; use let; never var

Use const to communicate immutability; it prevents accidental reassignment and simplifies refactors.

  • Default to const.
  • Use let only 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.

Use strict equality

Strict equality avoids coercion surprises; use == null only when you explicitly mean nullish.

  • Prefer === and !== to avoid coercion.
  • Use == null only when you mean nullish.
// ✅ strict equality
if (status === 'ok') return next()

// ✅ intentional nullish check
if (value == null) return defaultValue

// ❌ coercive equality
if (count == '0') return

Never inline statements with conditionals

This 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')

Prefer arrows & implicit return

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 pointless exotic syntax

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)

Use comma operator only for commit expressions

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))

Consider iteration over repetition

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 }

Keep lines ≤80; wrap by structure

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()
  }
}

Program flow

Patterns for control flow, sequencing, and conditionals.

Sequencing

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)))
}

Inline by default

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
}

Prefer vertical chaining

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
}

Allow pure recomputation

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 }
}

Separate transforms from effects

If mutation exists, make it explicit and commit immediately.

  • Prefer promise chains over await for straight pipelines.
  • Use await for 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))
}

Stay immutable where possible

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 = 3000

Group mutations into a single operation

Sequential 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]

Chain methods over nesting calls

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
  )

Conditionals

Prefer expressions over statement blocks.

  • Selectors and transforms should be expressions.
  • Use early returns only when statements are required.

Expressions instead of statements

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.nickname

Prefer 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()

Collapse repeated guards

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 false

Use functional methods over loops

Prefer 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])

Use for...of for sequential side effects

Sequential side effects need ordered execution.

for (const file of files)
  await upload(file)

Use Array.fromAsync for sequential results

Collect results in order without manual accumulation.

const responses = await Array.fromAsync(urls, url => fetch(url))

Use Promise.all for concurrent results

Run independent operations in parallel.

const responses = await Promise.all(urls.map(url => fetch(url)))

Use .allSettled, .race, .any when appropriate

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)

Functional Programming

Techniques for data transformation pipelines.

Use functional programming for data flows

When converting data, think pipelines, not procedures.

// ✅ Functional for transformations
const users = data
  .filter(active)
  .map(normalize)
  .sort(by.name)

Functional predicates

This section is about call-site clarity.
The goal is predicates that read like prose without creating a DSL.

Declutter functional pipelines using curried callbacks

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'))

Use domain namespaces for predicates

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)

Limit util/helper naming to test/throwaway scopes

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 in test/ 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'

Object-oriented Programming

When to use classes and how to design them well.

Use OOP for stateful entities

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 instanceof checking 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

What to model

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 }
}

What not to model

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)

Use methods to express behavior

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.

Use private fields to hide state

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 }
}

Choose method type by data source

Is it this.valid(this.status) or this.valid?
Is it calculate(amount) or this.calculated?

Static methods for pure operations

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 for computed properties

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 : 0

Name 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 for external interactions

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
  }
}

Never pass own properties

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 for semantic construction

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.

Design for marshalling

JSON.parse(JSON.stringify(instance)) loses prototype, methods, and private
state.

Object-parameter constructors

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.

Guard nested revival

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
  }
}

Allowlisted serialization

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.

Avoid cycles and non-serializable fields

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

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.

Define the base 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.

  1. Never inspect subtype identity in shared code.
  2. Never add variant-specific behavior to the base class.
  3. 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')
  }
}

Keep the dependency direction pointed inward

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.js

Hide the core behind the seam

The 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.

Prefer folder seams for taxonomy

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())
    )
  )
}

Extend via subclasses

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
  }
}

Separate compliance from ergonomics

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

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

Define behavioral contracts

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,
})

Avoid stronger preconditions

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),
})

Avoid weaker postconditions

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,
})

Keep side effects within the contract

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),
]

Recognize invariant clashes

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.

Enforce substitutability

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)

Error handling

Strategies for errors, validation, and input normalization.

Avoid defensive programming

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}`

Use appropriate error types

Choose error types deliberately and keep messages short, precise, and consistent.

Choose the right error class

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: Error with 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`)

Make messages concise and precise

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 message templates upfront

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`)

Prefer domain terms over synonyms

Use one domain term per state and stick to it.

  • If your domain says "settled", do not also say "paid".
  • Prefer Invoice already settled over Invoice has already been paid.
throw new Error('Invoice already settled')
throw new Error('Invoice has already been paid')

Keep related operations uniform

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`)

  // ...
}

Normalize & validate external input

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))
}

Testing

Guidelines for the Node.js built-in test runner.

Use built-in test runner

Use node --test.
Zero dependencies.

// ✅ Built-in test runner
// Run with: node --test "**/*.test.js"
import { test } from 'node:test'

Use global hooks via --import

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.

Propagate errors and set timeouts

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"

Rely on stable test discovery

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.js

package.json examples:

test: node --test "**/*.test.js"
test: node --test --import test/utils/setup.js "**/*.test.js"

Structure tests hierarchically

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
    })
  })
})

Write focused tests

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 fixtures to test context

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)
  })
})

Use context for test utilities

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 the minimum required

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',
    })
  })
})

Test for keywords, not sentences

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',
      })
    })
  })
})

Documentation

technical documentation guidelines

Philosophy

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.  

Use structured headings

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.

Break new sentences into newlines

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.  

Prefer to break into newline

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 consistent Markdown primitives

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/

Template

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.com

Style guide template

Pattern-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.

HTML/Markup

Minimal HTML/CSS for simple pages.

Principles

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 via pow().
  • Native: Use color-scheme, light-dark(), and system fonts.
  • Modern: Use nesting, pow(), text-wrap, and light-dark().
<main>
  <h1>Orders</h1>

  <section aria-labelledby="refunds">
    <h2 id="refunds">Refunds</h2>
    <p>Refunds require an order id.</p>
  </section>
</main>

Headings

Use headings for structure, not styling.

Rules:

  • one h1 per page
  • do not skip levels (h2h3, not h2h4)
  • 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>

Boilerplate

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment