style guide for modern JS
nicholaswmin
targets: node v25+, latest Win/MacOS/iOS Chrome/Safari
- Foundation
- Code organization
- Naming
- Syntax
- Use minimal semis, parens, and braces
- No comments, unless functionally necessary
- Prefer
const; uselet; nevervar - Use strict equality
- Keep only tiny controls inline
- 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
- Functional
- Program flow
- Sync iteration
- Async iteration
- Object-oriented
- 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 } = require('./data')
const data = require('./data.json')
const config = require('./config')
const validate = input => check(input)
const run = input =>
transform(input, { users, data, config })
module.exports = { validate, run }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"],
"engines": { "node": ">=24" },
"scripts": { "test": "node --test \"**/*.test.js\"" },
"keywords": ["foo", "bar"],
"author": "John Doe <johnk@doe.dom>",
"license": "MIT"
}Export from src/<name>/index.js.
Import with #<name>.
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.
// ✅ 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]])
)However, avoid DIY in production for domains that are:
- Too complex (game engines, CRDTs, date/time math)
- Too critical (cryptography, validation)
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)
}Patterns for structuring functions and managing complexity.
- 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)
}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 formatNameBad = (first, last, formatter) =>
formatter.format(first, last)Chain or inline unless the intermediate clarifies complex logic.
// ✅ No intermediates needed
const promote = users => users.map(review).filter(passed)
// ❌ Needless intermediates
const promoteBad = users => {
const reviewed = users.map(review)
const eligible = reviewed.filter(passed)
return eligible
}Some functions naturally orchestrate others.
Do not mangle business logic for brevity.
Do not make it verbose.
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 warnBad = message =>
console.warn(prefix, message)Prefer expressions for selectors and transforms.
Use early returns only when you need statements.
Early returns flatten procedures and eliminate branches early.
Each bailout removes a level of indentation.
The happy path stays obvious.
Guards are for bailouts in procedures.
For value selection, prefer expressions (?:, ||, &&, ??).
// ❌ 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 onUpdateBad(fence) {
if (!this.insulated) {
if (this.intersects(fence)) {
// ... sync logic
} else {
this.electrocute()
}
}
}However, do not use guards defensively.
Fix the caller instead of guarding against bad internal inputs.
Exception: validate external input at boundaries.
Enhance scannability by splitting sections into:
- setup
- execute
- output
// ✅ 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)
}Avoid blank lines just to preserve intermediates.
Prefer chaining.
This is generic.
It applies to functions, files, modules, and projects.
Guidelines for clear, intention-revealing names.
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 process = (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 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 userBad = { 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.
// ✅ 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 fps = { target: 60 }Use an object when:
- values are meaningless alone (start/end, x/y, min/max)
- they will be passed or returned as a unit
- the grouping has a name (range, point, bounds, config)
Use a flat binding when:
- the value stands alone
- no relationship to express
Flat is fine when:
- values have independent sources or lifetimes
- hot path where allocation matters
- React state (update ergonomics)
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 userBad = {
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 doubleBad = x => {
return x * 2
}
const userBad = { name: name, email: email }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(u => u.age > 10) // get active users
// ✅ Explain via naming
const activeUsers = users.filter(u => u.active)ASI: know the hazards, avoid the tricks.
No semis is fine.
Do not start a statement with these tokens.
If you must, prefix the line with ;:
([/(regex literal)+or-`(template literal)
Prefer rewriting to avoid these starts.
The guard semicolon is fine.
// ❌ 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 becomes a call of the previous expression
setup()
(() => start())()
// ✅ guard with leading semicolon
setup()
;(() => start())()// ❌ regex literal can parse as division
const input = read()
/^ok$/i.test(input)
// ✅ guard with leading semicolon
const input = read()
;/^ok$/i.test(input)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.
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') returnThis rule applies when you are already in statement form.
Selectors and transforms should stay expression-bodied.
Unusually tiny guards (≤25 chars) can stay inline.
Otherwise, break after the condition.
// ✅ Short controls inline
if (!user) return
// ✅ guard broken when it stops being tiny
if (!foobar)
return barbaz()
// ✅ Ternaries: break when it stops being tiny
const price = member
? discount(base)
: base
// ✅ Over ~40 chars: break
if (authenticated && verified && !suspended)
redirect('/dashboard')
for (const item of cart.items)
calculateTotal(item.price, item.quantity, tax)
// ❌ Short controls unnecessarily broken
if (ready)
start()
// ❌ Long conditions crammed inline
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 doubleBad = x => {
return x * 2
}
const getNameBad = user => {
return user.name
}Choose clarity over cleverness.
Use features when they improve readability.
// ✅ Clear intent
const isEven = n => n % 2 === 0
// ❌ Clever but unclear
const isEvenBad = 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.
// ✅ Dynamic generation
return Object.fromEntries(
['get', 'put', 'post', 'patch']
.map(method => [method, send])
)
// ❌ Manual enumeration
return { get: send, put: send, post: send, patch: send }Avoid overuse, especially in tests.
Loops need mental parsing.
Prefer to break by structure under ~60 chars.
Hard-limit at 80 unless extraordinary (long URLs, external APIs).
Break at natural boundaries (operators, delimiters, chain links).
For chains, use one call per line.
Keep operators at the end.
// ❌ dense, mixed precedence
const ok = () => a() || (b() && c(10) > 5)
// ✅ wrapped by structure; keep group parens
const ok = () =>
a() ||
(b() && c(10) > 5)// ❌ long chain kept inline
api().get().map().filter().reduce() + extra()
// ✅ one call per line; operator at end
api()
.get()
.map()
.filter()
.reduce() +
extra()// ✅ pipeline chaining — one link per line
const normalize = path =>
path
.replace(/\/$/, '')
.toLowerCase()
.trim()
// ❌ dense chain kept together
const normalizeBad = path =>
path.replace(/\/$/, '').toLowerCase().trim()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()
}
}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 turns f(a, b) into f(a)(b).
Use it to prefill configuration once.
Reuse the result as a single-argument function in map/filter/reduce.
Use when:
- you want data-last helpers that plug into array methods directly
- you reuse the same predicate/transform in multiple places
- you want pipelines without inline lambdas and wrapper noise
Avoid when:
- the helper is single-use; inline it
- it makes the call site harder to read
- partial application makes argument order ambiguous
If you do a significant amount of currying:
- consider a generic limited subset first
- keep domain-specific cases to a minimum
- avoid intentionally creating mini DSLs
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)
export const and = (...preds) =>
value => preds.every(pred => pred(value))
const eligible = and(
missing('active'),
is('plan')('pro'),
has('email')
)
users.filter(eligible)
items.filter(missing('active'))
items.filter(not(has('active')))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'Patterns for control flow, sequencing, and conditionals.
Keep control flow top-to-bottom.
Chain or inline unless the intermediate clarifies complex logic.
Skip braces and return for single expressions.
For chains, use one call per line.
Inside-out is hard.
Top-to-bottom is natural.
✅:
export const users = {
create: raw =>
Promise.resolve(raw)
.then(User.validate)
.then(User.from)
.then(user => repo.save(user))
}❌:
export const usersBad = {
create: raw => repo.save(User.from(User.validate(raw)))
}Inline by default.
Avoid intermediates.
✅:
export const norm = s => s.trim().toLowerCase()❌:
export const normBad = s => {
const x = s.trim().toLowerCase()
return x
}Prefer vertical chaining over temporary names.
✅:
export const slug = s =>
s
.trim()
.toLowerCase()
.replaceAll(' ', '-')❌:
export const slugBad = s => {
const a = s.trim()
const b = a.toLowerCase()
const c = b.replaceAll(' ', '-')
return c
}Allow pure recomputation to avoid braces and bindings.
If it is not hot-path, prefer clarity over performance.
✅:
export const userView = raw => ({
href: `/u/${raw.name.trim().toLowerCase()}`,
label: raw.name.trim().toLowerCase()
})❌:
export const userViewBad = raw => {
const name = raw.name.trim().toLowerCase()
return { href: `/u/${name}`, label: name }
}Keep transforms separate from effects.
If mutation exists, make it explicit and commit immediately.
✅:
export const users = {
incAge: user => (user.incAge(1), repo.save(user))
}❌:
export const usersBad = {
incAge: user => (incrementAge(user, 1), repo.save(user))
}Prefer functional promise chains over await for straight pipelines.
Use await when you need branching, try/catch, or early bailouts.
New values are easier to debug than mutations.
// ✅ 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 = 3000Entities with identity get classes.
Data gets transforms.
Inside-out is hard.
Top-to-bottom is natural.
// ✅ Multi-line chaining
const process = data =>
data
.filter(x => x > 0)
.map(x => x * 2)
.reduce((a, b) => a + b)
// ❌ Nested function calls
const processBad = 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 you need 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 roleBad = 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.
Avoid cascading negated guards for nested access.
Consolidate into one guard with optional chaining.
// ❌ stacked guards
if (!foo) return
if (!foo.bar) return
if (!foo.bar.baz) return doStuff()
// ✅ consolidated guard
if (!foo?.bar?.baz) return
// ✅ positive guard when action is short
if (foo?.bar?.baz) doStuff()// ✅ 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()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 active = []
for (let i = 0; i < items.length; i++)
if (items[i].active)
active.push(items[i])Patterns for async loops and concurrent operations.
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, fetch)Run independent operations in parallel.
const responses = await Promise.all(urls.map(fetch))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)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
// ✅ OOP for entities
class User {
constructor(name, role) {
this.name = name
this.role = role
this.score = 0
}
get eligible() { return this.score > 80 }
promote() {
this.role = 'senior'
return this
}
}- entities with identity and lifecycle
- actors that perform actions
- state machines with guarded transitions
- wrappers around stateful host APIs
- records, payloads, config objects
- value types with no behavior
- bags of utility functions
- one-off procedures
If a class exists, its behavior belongs on it.
Do not push behavior into external functions.
// ❌ Anemic
class User {
constructor(name, email) {
this.name = name
this.email = email
}
}
const validate = user => user.email.includes('@')
const greet = 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) are the idiomatic way to encapsulate state.
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 }
}Use them when:
- state should not leak to JSON serialization
- invariants must be enforced (no external mutation)
- implementation details may change
Avoid when fields must serialize or when the ceremony outweighs the benefit
(simple DTOs, short-lived objects).
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 Engine {
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 Car {
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('type required')
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 Node 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 the main workflow stable.
New cases are added by extending small units, not editing the runner.
Use when:
- new cases get added regularly
- changing the workflow risks regressions
- adding a case should not modify existing cases
- the need for extensibility is explicit, not speculative
Find the part that keeps changing.
Turn that variation into one seam where new behavior plugs in.
Do this:
- keep the workflow focused on ordering and composition
- move branching logic into replaceable extensions
- keep the seam small enough to test in isolation elsewhere
A contract is more than "has the right method name".
It also defines valid inputs, output invariants, allowed side effects,
and error behavior for valid inputs.
Contract should include:
- accepted inputs and required fields
- returned type and invariants
- allowed side effects
- error behavior on valid inputs
The runner calls a base API.
You extend behavior by adding a new subclass.
Use when:
- variants share defaults, state, or lifecycle helpers
- you want nominal checks (
instanceof Rule)
export class Rule {
apply(state, context) {
throw new Error('Rule.apply must be implemented')
}
}
export class Engine {
#rules
constructor(rules = []) {
this.#rules = [...rules]
}
with(rule) {
return new Engine([...this.#rules, rule])
}
run(context) {
return this.#rules.reduce(
(state, rule) => rule.apply(state, context),
context.initial
)
}
}
export class Multiply extends Rule {
#factor
constructor(factor) {
super()
this.#factor = factor
}
apply(state, context) {
return state * this.#factor
}
}
export class Add extends Rule {
#amount
constructor(amount) {
super()
this.#amount = amount
}
apply(state, context) {
return state + this.#amount
}
}const result = new Engine()
.with(new Multiply(2))
.with(new Add(5))
.run({ initial: 10 })
resultIf something matches the contract, treat it like a rule.
The runner does not care about inheritance.
Use when:
- inheritance is overkill but you still want OCP
- extensions are small and easiest as factories
export class Engine {
#rules
constructor(rules = []) {
this.#rules = [...rules]
}
with(rule) {
return new Engine([...this.#rules, rule])
}
run(context) {
return this.#rules.reduce(
(state, rule) => rule.apply(state, context),
context.initial
)
}
}
export const multiply = factor => ({
apply: (state, context) => state * factor,
})
export const add = amount => ({
apply: (state, context) => state + amount,
})const result = new Engine()
.with(multiply(2))
.with(add(5))
.run({ initial: 10 })
resultIf extensions do not need identity or lifecycle, functions are a clean seam.
Add behavior by appending a function.
Use when:
- each extension is a pure transform of a value or state
- you want minimal ceremony and direct unit tests elsewhere
export const run = (context, rules = []) =>
rules.reduce(
(state, rule) => rule(state, context),
context.initial
)
export const multiply = factor =>
(state, context) => state * factor
export const add = amount =>
(state, context) => state + amountconst result = run(
{ initial: 10 },
[multiply(2), add(5)]
)
resultThis is the "data drives behavior" seam.
You register handlers once, and external data chooses which runs.
Use when:
- behavior is selected from external data (
{ type, ...params }) - you need plugins without editing the core runner
export const createEngine = () => {
const rules = new Map()
const use = (type, apply) => {
if (rules.has(type))
throw new Error(`Rule already registered: ${type}`)
rules.set(type, apply)
return api
}
const run = (context, specs = []) =>
specs.reduce((state, spec) => {
if (!spec?.type)
throw new Error('Rule spec.type required')
const apply = rules.get(spec.type)
if (!apply)
throw new Error(`Unknown rule: ${spec.type}`)
return apply(state, context, spec)
}, context.initial)
const api = { use, run }
return api
}import { createEngine } from './engine.js'
const engine = createEngine()
.use('multiply', (state, context, spec) => state * spec.factor)
.use('add', (state, context, spec) => state + spec.amount)
const result = engine.run(
{ initial: 10 },
[
{ type: 'multiply', factor: 2 },
{ type: 'add', amount: 5 },
]
)
resultLiskov 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
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
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
Side effects are not always bad.
They must be predictable.
Avoid:
- hidden network or filesystem work
- hidden mutation of shared state
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 Ellipse {
constructor(a, b) {
this.a = a
this.b = b
}
setA(a) {
this.a = a
return this
}
setB(b) {
this.b = b
return this
}
}
export class Circle extends Ellipse {
constructor(r) {
super(r, r)
}
setA(r) {
this.a = r
this.b = r
return this
}
setB(r) {
this.a = r
this.b = r
return this
}
}
export const stretchX = (shape, a) =>
shape.setA(a)Callers that rely on setA changing only a will be surprised by Circle.
Treat the seam contract as enforceable, not aspirational.
Use one shared expectation set that every rule must satisfy.
Strategies for errors, validation, and input normalization.
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 processBad = 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 formatNameBad = (first, last) => {
if (!first) return ''
if (!last) return ''
return `${first} ${last}`
}
// ✅ Trust the caller
const formatName = (first, last) => `${first} ${last}`Be specific:
- wrong type:
TypeError - out of bounds:
RangeError - everything else:
Errorwith a clear message
// ✅ Specific error types
if (typeof value !== 'number')
throw new TypeError(
`Expected number, got ${typeof value}`
)
if (value < 0 || value > 100)
throw new RangeError('Value must be between 0 and 100')
if (!process.env.API_KEY) throw new Error('API_KEY missing')
// ❌ String errors
throw 'Invalid value'
// ❌ Generic errors
throw new Error('Invalid 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 Error('userId required')
if (typeof req.body.userId !== 'string')
throw new TypeError('userId must be string')
return processUser(req.body.userId)
}
// ✅ Early validation
const createUser = data => {
if (!data.name) throw new Error('Name required')
if (!data.email) throw new Error('Email required')
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.
Important
Propagate unhandled errors so failures surface immediately.
Set sensible timeouts to prevent indefinite hangs.
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"--test-timeout=<ms>sets per-test timeout (default: Infinity)- exit on unhandled errors; never swallow silently
- prefer short timeouts; long waits hide real problems
Keep the test command stable as the tree grows.
Use one glob that includes co-located tests but excludes helpers.
Do this:
node --test "**/*.test.js"Avoid narrow paths that miss 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)
})
})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
- scannable at a glance
- get to the point fast
- strip noise and repetition
Start each sentence on its own line for cleaner diffs.
End each prose line with two spaces to force a hard line break.
Lorem ipsum dolor sit amet.
Consectetur adipiscing elit.
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Validation rules for all technical documentation.
- Title: Single
#heading naming the scope - Headers:
- Shape: 1–10 words
- Style: verbs (Use/Never/Prefer/Avoid) or nouns for reference sections
- Patterns:
[Verb] [Subject],X instead of Y,If X, do Y - Spacing:
- One empty line before and after each header
- Never place a header directly after another header
- Always have at least one non-header line between headers
- Section openings:
- Prose sections need ≥1 context line after the header
- Reference sections with obvious content are exempt
- Sentences: ≤20 words; active voice; neutral tone; no filler
- Lists:
- Bullets for enumeration
- Numbered lists for stepped workflows
- Tables: Columns must be aligned
- Pseudo-headers:
**Label:**format- One empty line before and after
- Emoji allowed for patterns (❌/✅)
- Use
**❌:**and**✅:**only in style guides
- Line wrap:
- Soft: 80 chars
- Hard: 90 chars
- End wrapped prose lines with two spaces
- Never rely on editor soft wrap
- Break at spaces or punctuation
- Never split words or text inside parens or code
- Links: Reference-style only; URLs at end
- Code:
- Language tags on fenced blocks
- Use backticks inline for code, commands, paths, and filenames
Break at semantic boundaries (punctuation, clauses, logical units).
Do not break at arbitrary character counts.
❌:
The `--import` flag loads global test hooks, but you still need to set
timeouts and propagate errors. ✅:
The `--import` flag loads global test hooks,
but you still need to set timeouts and propagate errors. 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().
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
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>
body {
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>