The previous lesson ended with an uncomfortable observation: the three bugs you solved all had an earlier signal that nobody saw. An id mixing types, a catch that silently swallowed an error, two twin lines of which only one was adapted. None of them needed to run in order to look suspicious: it was enough to read the code carefully, and a machine does that better than you, in a hundred milliseconds and without getting tired. In this lesson you will set that machine up over Nómada Tasks: ESLint to spot dangerous patterns before anything runs, Prettier so that formatting stops being a topic of conversation, a Git hook so nothing badly formatted enters the repository, and a continuous integration workflow that checks it all over again on the server. And you will finish by settling the conventions no tool can automate: names, function size, comments that explain the why, and type documentation with JSDoc.
Contents
- What it costs not to have quality tools
- Static analysis: what a machine can know without running anything
- ESLint: installing and first run
- The flat configuration file
- Rules, levels and recommended configurations
globals: browser, Node and tests- Ten rules that prevent real bugs
- Turning a rule off without cheating
--fixand the project scripts- Useful plugins
- Prettier: formatting stops being an opinion
- ESLint versus Prettier, and how to make them coexist
- Editor integration
- Git hooks with Husky and lint-staged
- Continuous integration with GitHub Actions
- Conventions no tool can enforce
- Documenting types with JSDoc
// @ts-check: type checking without TypeScript- Quality metrics, used sensibly
- Nómada Tasks: setting it all up and fixing the warnings
- Common Mistakes and Tips
- Exercises
- Conclusion
- What it costs not to have quality tools
In a project with no automation, quality rests on human review. And human review has an attention budget problem: the reviewer has a limited amount of energy, and if it goes on things a machine would sort out on its own, there is none left for what only a human can see.
A typical review comment with no tooling:
"There are double quotes here and single quotes everywhere else in the file. The semicolon on line 34 is missing. The
ifis indented with 4 and we use 2. And I thinkstatusis unused."
Four comments; all four are detected and fixed by a tool in one second. What nobody said, because the attention went on formatting: that this if does not handle the done case, or that the function returns undefined on one branch.
The real cost breaks down into four parts:
| Cost | What it is | What eliminates it |
|---|---|---|
| Arguing about style | Quotes, indentation, semicolons, line length | A deterministic formatter |
| Noise in diffs | Formatting changes mixed with logic changes | The same formatter, applied by everyone |
| Pattern bugs | Unused variables, unexpected ==, promises with no await |
A static analyzer |
| Drift between people | Every file written in a different style | Shared configuration in the repository |
The first two are about comfort. The third is about correctness, and it is the one that justifies the lesson: there is a whole family of bugs that can be found by reading the code, without running it.
- Static analysis: what a machine can know without running anything
A static analyzer turns your code into an abstract syntax tree (AST) —the very structure the JavaScript engine builds before running— and walks that tree looking for patterns. It runs nothing; it reasons about shape.
flowchart LR
A["Source code<br/>board.js"] --> B["Analyzer<br/>→ AST"]
B --> C["Rules<br/>walk the tree"]
C --> D["Warnings and errors<br/>file:line:column"]
C --> E["Automatic<br/>fixes (--fix)"]
What an analyzer can know:
- That you declared
const visibleand never used it. - That you call
bord.summary()and that identifier does not exist in any reachable scope. - That an
asyncfunction contains noawaitat all (theasyncis probably unnecessary… or anawaitis missing). - That a
switchcasehas nobreakand falls through to the next one (02-03). - That you compare with
==instead of===(01-07). - That there is an assignment inside an
if:if (status = 'done').
What it cannot know:
- Whether
openHoursshould add up the open tasks or all of them. That is semantics, and it is exactly case 3 from the previous lesson. That needs tests. - Whether the application does what Marta needs.
- Whether your API will return
idas a string or as a number, unless you tell it with types.
It is important to be clear about that boundary: static analysis and automated tests do not compete, they complement each other. The first is cheap, instant and covers a narrow family of bugs; the second costs effort to write and covers behavior. A serious project has both.
- ESLint: installing and first run
ESLint is the standard static analyzer of the JavaScript ecosystem. It is configurable down to the last detail and extensible with plugins, and those two characteristics explain both its power and its reputation for "complicated configuration".
Nómada Tasks has had no package.json so far: it is a project of ES modules served directly. We create one now, because from here on the project has tooling:
cd nomada-tasks
npm init -y # creates package.json
npm install --save-dev eslint # ESLint is only needed in developmentOne detail worth settling in package.json from the start:
{
"name": "nomada-tasks",
"version": "1.0.0",
"type": "module",
"private": true,
"scripts": {
"lint": "eslint ."
},
"devDependencies": {
"eslint": "^9.0.0"
}
}"type": "module"tells Node that the.jsfiles in this project are ES modules (05-04), not CommonJS. Without this line, any tool running your code in Node would complain about theimportstatements."private": truestops you publishing the package to npm by accident.devDependenciesrather thandependencies: ESLint is not part of the application, only of the development process. The browser never downloads it.
Running npm run lint with no configuration, ESLint warns that it cannot find a configuration file. Let us write it.
- The flat configuration file
Modern ESLint uses flat config: an eslint.config.js file that exports an array of configuration objects. Each object says which files it applies to and which rules govern there. Later objects layer over earlier ones.
// eslint.config.js
import js from '@eslint/js';
import globals from 'globals';
export default [
// ── Layer 0 · what is NOT analyzed ────────────────────────────────────
{
ignores: ['coverage/**', 'dist/**', 'node_modules/**']
},
// ── Layer 1 · baseline for ALL the JavaScript in the project ──────────
js.configs.recommended,
// ── Layer 2 · the application code: browser + ES modules ──────────────
{
files: ['js/**/*.js'],
languageOptions: {
ecmaVersion: 'latest', // newest syntax (private # fields, ?., ??=)
sourceType: 'module', // import/export, not require
globals: {
...globals.browser // window, document, fetch, localStorage, console…
}
},
rules: {
// the project's own rules go here (section 7)
}
},
// ── Layer 3 · the service worker lives in ANOTHER global environment ──
{
files: ['sw.js'],
languageOptions: {
globals: { ...globals.serviceworker } // self, caches, clients… but NOT document
}
},
// ── Layer 4 · tooling files that run in Node ──────────────────────────
{
files: ['*.config.js', 'scripts/**/*.js'],
languageOptions: {
globals: { ...globals.node } // process, __dirname (where it applies), console
}
}
];Five things to understand about this file, because they are the ones that cause confusion:
- It is real JavaScript, not JSON. You can import, compute and compose. That is why
js.configs.recommendedis an object inserted into the array. - Order matters. If two layers define the same rule, the last one wins. That is why the recommended configuration goes at the top and your adjustments below.
filesdecides the scope. An object with nofilesapplies to everything. That is what makes layer 3 possible:sw.jshas nodocumentorwindow, and does haveselfandcaches; declaring it avoids hundreds of falseno-undefreports.ignoresin an object of its own (with nofiles) acts as a global ignore, the equivalent of the old.eslintignore.globalsis a helper package holding the catalogs of global variables for each environment. It is installed separately:npm i -D globals.
Layer 3 deserves one more comment. The service worker you wrote in 07-05 runs on a different thread with a different global object. Without that layer, ESLint would flag self, caches and clients as undefined, and —worse— it would not flag an accidental use of document, which in a worker is a genuine runtime error. Configuring environments properly is not bureaucracy: it is what turns no-undef into a real bug detector.
- Rules, levels and recommended configurations
Every ESLint rule is configured with a level and, optionally, options:
rules: {
'no-unused-vars': 'error', // plain level
'no-console': ['warn', { allow: ['warn', 'error'] }], // level + options
'no-alert': 'off' // turned off
}| Level | Numeric value | What happens | Exit code |
|---|---|---|---|
'off' |
0 |
The rule is not checked | — |
'warn' |
1 |
It is reported, but does not fail | 0 (success) |
'error' |
2 |
It is reported and fails | 1 (failure) |
The difference between warn and error becomes critical the moment you add continuous integration: error breaks the build, warn does not. Hence a very practical strategy:
errorfor anything that is a real bug or a risk:no-undef,eqeqeq,no-debugger.warnfor what is preference or in migration: rules you have just switched on over existing code and that still produce a hundred warnings.- And a hygiene rule: warnings must not pile up indefinitely. If a warning has been sitting there for six months, either it gets fixed or it gets switched off with a written reason. A lint run that prints 300 warnings is a lint run nobody reads.
js.configs.recommended switches on around sixty rules the community considers essential, and none about style. That is deliberate: ever since Prettier appeared, ESLint has been withdrawing formatting rules from its recommendation. That division of labor is the subject of section 12.
globals: browser, Node and tests
globals: browser, Node and testsThe no-undef rule flags any identifier that is not declared. It is one of the most valuable —it would have caught a bord.summary() instantly— but it only works if ESLint knows which globals are legitimate in each file.
import globals from 'globals';
// Browser: window, document, fetch, localStorage, CustomEvent, AbortController…
globals.browser
// Service worker: self, caches, clients, skipWaiting…
globals.serviceworker
// Node: process, console, Buffer, URL…
globals.node
// Jest (you will need it in 08-03): describe, test, expect, beforeEach…
globals.jestAnd if you need to declare a global of your own —for example, a constant injected by the deployment process— you do it with its write permission stated explicitly:
Marking it 'readonly' has an extra effect: the no-global-assign rule will warn if somebody tries to reassign it.
- Ten rules that prevent real bugs
These are the ones that justify the whole setup. They are not a matter of taste: each one corresponds to a family of bugs that has cost somebody an afternoon.
| Rule | What it detects | Example in Nómada Tasks |
|---|---|---|
no-unused-vars |
Unused variables, parameters and imports | An import { WEIGHTS } left over from a refactor: dead code that confuses |
no-undef |
Undeclared identifiers | bord.summary(), a TODAY that was never imported |
eqeqeq |
== and != instead of === / !== |
Exactly the 01-07 coercion that masked case 1 in 08-01 |
no-implicit-globals |
Declarations that pollute the global object | A stray var status in a classic script |
require-await |
An async function with no await inside |
async function save() that is actually synchronous: it promises asynchrony that is not there |
no-return-await |
An unnecessary return await x inside a try… or outside it |
An extra stack frame for no gain |
no-fallthrough |
A case that falls through to the next without break |
The status switch from 02-03 |
no-cond-assign |
Assignment inside a condition | if (task.status = 'done') — it changes the status and is always true |
no-debugger |
The debugger statement |
Exactly what you left behind in 08-01 |
no-constant-condition |
Conditions that are always true or always false | `if (task.estimatedHours |
And an eleventh that deserves separate explanation because it is the most quoted and the most misunderstood: no-floating-promises.
A floating promise is a promise whose result nobody collects:
// Can you see the bug?
function onSaveClick() {
repository.save(board);
updateTask(task.id, { status: 'done' }); // ← returns a promise nobody awaits
showNotice('Saved'); // ← a lie: it has not been saved yet
}If updateTask fails, nobody catches the rejection: it becomes an unhandledrejection (05-06) and the user sees "Saved" over a change that never reached the server. It is a silent bug of the worst kind.
The important nuance: the real no-floating-promises rule requires type information, and that comes from TypeScript (section 18), not from ESLint over plain JavaScript. In a JavaScript-only project you cover it with a combination of approximations (require-await, no-async-promise-executor, review) and, above all, with a team convention: every call that returns a promise is either awaited or chained with an explicit .catch(). Writing it into the conventions is half the work; the other half is getting TypeScript to check it, and that is where // @ts-check starts to look attractive.
- Turning a rule off without cheating
There will be legitimate cases where a rule gets it wrong. ESLint lets you silence it with surgical precision:
// Only the next line
// eslint-disable-next-line no-console -- deliberate diagnostic log (see 08-01)
console.log('[nomada] diagnostics mode on');
// Only this line, at the end
const fallback = inMemoryStore(); // eslint-disable-line no-unused-vars
// A whole block
/* eslint-disable no-undef -- this file runs inside the service worker */
self.addEventListener('install', …);
/* eslint-enable no-undef */Four hygiene rules for these comments:
- Always name the rule. A bare
// eslint-disable-next-lineswitches off every rule on that line, including ones that do not exist yet. - Write the reason after
--. ESLint supports that syntax for exactly this purpose, and it stops anyone a year from now wondering whether it is still needed. - Prefer
disable-next-lineover a blockdisable. A disabled block tends to grow and hide new things. - If you disable it in five places, the rule is misconfigured. Change it in
eslint.config.jsor switch it off globally with a comment that justifies it. Five exceptions are not exceptions: they are the real rule.
There is also the reportUnusedDisableDirectives option (on by default in the modern recommended configuration), which warns when an eslint-disable is no longer needed because the code changed. It is automatic cleanup of your own exceptions.
--fix and the project scripts
--fix and the project scriptsMany rules are auto-fixable: ESLint knows how to rewrite the code to satisfy them without changing its meaning.
npx eslint . # only reports
npx eslint . --fix # fixes what it can and reports the rest
npx eslint . --fix-dry-run --format json # simulates, without writingWhat gets fixed on its own and what does not:
| Fixed automatically | Requires human judgment |
|---|---|
| Quotes, semicolons, indentation | no-unused-vars (is the variable superfluous, or is a use missing?) |
== → === when it is safe |
no-undef (is an import missing, or is it a typo?) |
let → const if it is never reassigned |
require-await (is the async superfluous, or is an await missing?) |
| Import order (with a plugin) | Excessive complexity |
The mental rule: --fix solves the form, never the intent. Anything that involves deciding what you meant to do is left to you, and that is right.
The scripts the project will have from here on:
{
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"verify": "npm run lint && npm run format:check"
}
}verify is the script continuous integration will run, and the one you can fire before every commit while you get used to it.
- Useful plugins
An ESLint plugin contributes new rules. These three cover real needs of a project like this one:
Imports. An import plugin (eslint-plugin-import or its modern successor eslint-plugin-import-x) checks what the browser does not forgive in ES modules: paths that do not exist, forgotten extensions, circular dependencies and import ordering.
{
files: ['js/**/*.js'],
plugins: { import: importPlugin },
rules: {
'import/no-unresolved': 'error', // the path './model/task.js' really exists
'import/extensions': ['error', 'always'], // in the browser the extension is MANDATORY
'import/no-cycle': 'error', // A imports B, B imports A → unpredictable execution order
'import/order': ['warn', { 'newlines-between': 'always' }]
}
}import/extensions set to 'always' is especially valuable here: bundlers tolerate import { Task } from '../model/task' without the extension, but the browser with <script type="module"> does not. Without this rule, the failure shows up at runtime and only in the browser. And import/no-cycle protects the acyclic dependency graph you drew in 05-04.
Accessibility. The best-known accessibility plugin (eslint-plugin-jsx-a11y) is designed for JSX, which you do not use. In an HTML-and-DOM project the equivalent check is done with other tools: an HTML validator, the DevTools accessibility audit, and above all axe, which you will wire into your end-to-end tests in 08-06. It is worth knowing the rule family exists and why it does not fit here: choosing a plugin because it sounds good and then discovering it does not analyze your kind of file is a very common way to lose an afternoon.
Tests. eslint-plugin-jest catches classic mistakes in the tests you will write in the next lesson: a test with no assertions at all, an expect outside a test, a forgotten test.only that leaves the rest of the suite unrun —perhaps the most dangerous failure of all, because the suite stays green while it checks nothing.
{
files: ['**/*.test.js'],
plugins: { jest: jestPlugin },
languageOptions: { globals: { ...globals.jest } },
rules: {
'jest/no-focused-tests': 'error', // a forgotten test.only
'jest/no-disabled-tests': 'warn', // a test.skip that has been there for months
'jest/expect-expect': 'error', // a test with no assertions proves nothing
'jest/valid-expect': 'error'
}
}
- Prettier: formatting stops being an opinion
Prettier is not a linter: it is a deterministic formatter. It discards your code's original formatting entirely, rebuilds it from its syntax tree and prints it following its own rules. Two enormous consequences:
- The result does not depend on how it was written before. Two people with opposite styles produce byte-for-byte identical files.
- There is barely anything to configure. Prettier deliberately offers few options, so that the argument does not simply move into the configuration.
// .prettierrc.json — the complete configuration for a project like this
{
"semi": true,
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"trailingComma": "none",
"arrowParens": "always",
"endOfLine": "lf"
}A comment on each option, because these are all the ones you have to decide:
semi: semicolons at the end. Using them avoids the whole class of automatic-insertion surprises (01-04).singleQuote: single quotes, consistent with all the code in the course.printWidth: 100: maximum width before wrapping. 80 is the classic; 100 breathes better on modern screens and avoids breaking.filter().map().reduce()chains.trailingComma: trailing comma."none"keeps the project's style;"all"produces cleaner diffs when you add items. Either is fine: what is not fine is every file using a different one.endOfLine: "lf": Unix line endings. Without this, a mixed Windows/macOS team generates whole-file diffs from invisible changes.
And an exclusions file:
Usage:
npx prettier --write . # formats the whole project
npx prettier --check . # only checks; fails if something is not formatted (for CI)When to adopt it. Running prettier --write . over an existing project produces a gigantic commit touching every file. Do it in an isolated commit that does not change a single line of logic, with a clear message ("format: apply Prettier across the project"). And add its hash to a file so git blame ignores it:
echo "d4e5f6a7b8c9 # format: Prettier across the project" >> .git-blame-ignore-revs
git config blame.ignoreRevsFile .git-blame-ignore-revsWith that, git blame will keep attributing each line to whoever wrote its logic, not to the formatting commit. It is a small detail that saves a lot of future frustration.
- ESLint versus Prettier, and how to make them coexist
The most common confusion in the ecosystem. Both read your code and both can modify it, but they answer different questions:
| ESLint | Prettier | |
|---|---|---|
| Question it answers | Is this code correct and safe? | Is this code well printed? |
| Kind of analysis | Semantic: scopes, flow, variable usage | Syntactic: it reprints the AST |
| Example of what it detects | Unused variable, ==, debugger |
A 180-character line, mixed quotes |
| Configuration | Extensive: hundreds of rules | Minimal and deliberately limited |
| Can it fix things? | Some rules, with --fix |
Everything, always |
| Is it debatable? | Yes, every rule gets argued over | No: you accept its judgment and stop arguing |
| If you remove it | Bugs appear | Arguments appear |
The conflict and its solution. ESLint still ships some legacy formatting rules (indentation, quotes…). If you switch them on, ESLint and Prettier can demand opposite things and you will end up in a loop: you save, Prettier formats, ESLint complains; you fix it, Prettier undoes it.
The standard solution is eslint-config-prettier: a configuration that switches off every ESLint rule that clashes with Prettier. It goes last in the array, so its deactivation wins:
// eslint.config.js
import js from '@eslint/js';
import globals from 'globals';
import prettier from 'eslint-config-prettier';
export default [
js.configs.recommended,
{ files: ['js/**/*.js'], languageOptions: { globals: globals.browser }, rules: { /* … */ } },
prettier // ← ALWAYS last: turns off whatever would clash
];There is also eslint-plugin-prettier, which runs Prettier inside ESLint and reports every formatting difference as an error. It is convenient (one single tool) but it fills the output with formatting errors mixed in with real ones and slows the analysis down. The widespread recommendation is a clean separation: Prettier formats, ESLint analyzes, eslint-config-prettier keeps them in their lanes.
flowchart LR
A["You save the file"] --> B["Prettier<br/>reprints the formatting"]
B --> C["ESLint<br/>analyzes correctness"]
C --> D{"Errors?"}
D -->|No| E["Done"]
D -->|Yes| F["You fix them<br/>(or eslint --fix)"]
F --> C
- Editor integration
Everything above becomes invisible —and therefore useful— when the editor applies it on its own.
// .vscode/settings.json — committed to the repository: the same for the whole team
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.useFlatConfig": true,
"files.eol": "\n",
"files.insertFinalNewline": true,
"files.trimTrailingWhitespace": true
}What each line does: Prettier formats on save; ESLint applies its automatic fixes on save too; line endings and trailing whitespace are normalized. The practical result is that you stop thinking about formatting: you type however it comes out, you save, and it ends up correct.
And an .editorconfig for anyone using a different editor:
# .editorconfig
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = falseThat last section matters: in Markdown, two spaces at the end of a line mean a line break, so trimming them would break the text.
- Git hooks with Husky and lint-staged
The editor covers whoever has it properly configured. The repository needs a guarantee that does not depend on that. Git hooks are scripts Git runs at specific moments; the one we care about is pre-commit, which runs before the commit is created and can abort it.
- Husky manages the hooks inside the repository (Git does not version
.git/hooks, so without a tool everybody would have to install them by hand). - lint-staged runs the tools only over the files in the staging area. That is what makes the difference between a two-second hook and a two-minute one.
// package.json
{
"lint-staged": {
"*.js": ["eslint --fix", "prettier --write"],
"*.{json,css,md,html}": ["prettier --write"]
}
}The complete flow, step by step:
flowchart TD
A["git commit"] --> B["Husky runs<br/>.husky/pre-commit"]
B --> C["lint-staged takes<br/>ONLY the staged files"]
C --> D["eslint --fix<br/>prettier --write"]
D --> E{"Any errors left<br/>unfixed?"}
E -->|Yes| F["Commit ABORTED<br/>with the list"]
E -->|No| G["The fixes are re-added<br/>to the staging area"]
G --> H["Commit created"]
The key detail in step G: lint-staged re-adds the files its tools modified, so the commit contains the already-formatted code. You do not have to do anything.
Two essential warnings:
- A slow hook gets disabled. If
pre-committakes thirty seconds, somebody will start using--no-verifyand the guard will cease to exist. That is why lint-staged only looks at what is staged, and why the full test suite does not belong inpre-commit: it goes inpre-pushor straight into continuous integration. --no-verifyexists and is sometimes legitimate (an emergency production commit at three in the morning). Precisely for that reason the same check has to be repeated on the server, where nobody can skip it.
- Continuous integration with GitHub Actions
Continuous integration (CI) runs the checks on a clean server, on every push and every pull request. It is the only layer nobody can dodge, and it also eliminates the classic "it works on my machine": the server starts from scratch, installs exactly what the lock file says and runs the same thing for everybody.
# .github/workflows/quality.yml
name: Quality
# When it runs
on:
push:
branches: [master]
pull_request:
jobs:
verify:
runs-on: ubuntu-latest # a clean virtual machine for every run
steps:
# 1 · Bring the repository code onto the machine
- name: Check out the code
uses: actions/checkout@v4
# 2 · Install Node. 'cache: npm' reuses dependencies between runs
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: npm
# 3 · Reproducible install: honors package-lock.json to the letter
- name: Install dependencies
run: npm ci
# 4 · Static analysis. Fails if there are errors (level 'error')
- name: Analyze with ESLint
run: npm run lint
# 5 · Formatting. It does not format: it checks. Fails if anything is unformatted
- name: Check the formatting
run: npm run format:checkPoints worth understanding about this workflow:
npm ciinstead ofnpm install.cideletesnode_modules, installs exactly the versions inpackage-lock.jsonand fails if the lock does not matchpackage.json. It is deterministic;installmay update the lock along the way.node-version: lts/*uses the current long-term support release, without pinning a number that will go stale. If your project needs a specific version, declare it in theenginesfield ofpackage.jsonand mirror it here.format:check, notformat. CI never modifies the code: it checks and it fails. Fixing is the author's job, on their machine.on: pull_requestis what makes the green or red mark appear on the proposed change, before merging.
When you get to 08-03 you will add an npm test step to this same workflow, and in 08-06 another one with the end-to-end tests. The structure is already in place.
The three layers of defense, ordered by how quickly they respond:
| Layer | When it acts | Can it be bypassed | Cost |
|---|---|---|---|
| Editor | On save | Yes (just do not install the extension) | 0 s |
| Git hook | On commit | Yes (--no-verify) |
1–3 s |
| Continuous integration | On push / PR | No | 30–90 s |
All three are the same check repeated, and that redundancy is deliberate: the sooner it fails, the cheaper it is to fix.
- Conventions no tool can enforce
With formatting and analysis automated, what is left is what requires judgment. These are the conventions worth writing into the project's CONTRIBUTING.md.
Names (picking up 01-04). A name is the documentation that never goes stale, because it is read at every use.
| Element | Convention | In Nómada Tasks |
|---|---|---|
| Variables and functions | camelCase, a verb if it acts |
openHours, createBacklog(), paintCard() |
| Classes | PascalCase, a noun |
Task, Board, LocalRepository, ApiError |
| Module constants | UPPERCASE_WITH_UNDERSCORES |
TODAY, WEIGHTS, TRANSITIONS, EVENTS |
| Private fields | # in front (05-03) |
#status, #tasks, #store |
| Booleans | An is/has prefix |
isOpen, isOverdue(), persistent |
| Files | kebab-case.js, singular if it exports one thing |
local-repository.js, board-view.js |
| Handlers | on + event, or handle + thing |
onCreate, handleClick |
And three anti-names to stamp out: data, info, manage. They say nothing. data can be anything; pendingTasks says exactly what it is.
Function size. There is no magic number, but there is a reliable test: if you need a comment to separate two parts of a function, those two parts are two functions. As a practical reference: above 30 lines it is worth eyeing with suspicion, above 50 it almost certainly does two things. And the decisive criterion is not length but level of abstraction: a function that mixes deciding what to render with how to write it into the DOM is already wrong, whether it is ten lines or a hundred.
Comments that explain the why. A comment that repeats what the code says is noise that also ages badly:
// ❌ Noise: it repeats the code
// Increment the counter by one
counter += 1;
// ❌ Worse: it lies, because the code changed and the comment did not
// Returns the tasks sorted by date
return tasks.sort((a, b) => WEIGHTS[b.priority] - WEIGHTS[a.priority]);
// ✅ Useful: it explains a decision the code cannot tell you about
// We return fresh instances on every call so that two consumers
// (the application and the tests) do not share state by accident.
export function createBacklog() { … }
// ✅ Useful: it documents an external constraint
// The browser does NOT distinguish a dead network from blocked CORS: both arrive as
// TypeError, deliberately, so as not to leak information about other domains.
throw new ApiError('Could not reach the server.', { code: 'network' });
// ✅ Useful: it flags a trap
// dataset ALWAYS returns strings; without Number() the === in findById fails.
const id = Number(li.dataset.id);The rule: the code says what it does; the comment says why it is like that and not otherwise. If you need a comment to explain what it does, the fix is usually to rename or extract a function, not to add a comment.
- Documenting types with JSDoc
JSDoc documents types with structured comments. In plain JavaScript it brings two immediate benefits: autocompletion and warnings in the editor (which understands JSDoc natively), and documentation you can read without leaving the file.
/**
* Filters and sorts the tasks for presentation, without modifying the board.
*
* @param {import('../model/board.js').Board} board Data source
* @param {object} options
* @param {string|null} [options.assignee] Exact name, or null for no filter
* @param {string} [options.text=''] Case-insensitive search by title
* @param {'priority'|'date'|'hours'} [options.sort='priority']
* @returns {import('../model/task.js').Task[]} A NEW array; the board is untouched
* @throws {ValidationError} If `sort` is not one of the accepted values
*
* @example
* visibleTasks(board, { assignee: 'Iván' }); // → 3 tasks, 25 h
*/
export function visibleTasks(board, { assignee = null, text = '', sort = 'priority' } = {}) {
// …
}And for types that repeat, @typedef defines them once:
/**
* @typedef {object} BoardSummary
* @property {number} total Every task on the board
* @property {number} open The ones that are not in the 'done' status
* @property {number} totalHours Sum of estimatedHours across all of them
* @property {number} openHours Sum of estimatedHours across the open ones
* @property {number} overdue Open tasks with a dueDate in the past (R10)
* @property {number} effort Sum of hours × priority weight
*/
/**
* @param {string} today Reference ISO date
* @returns {BoardSummary}
*/
summary(today) { … }That @typedef documents the project's canonical numbers once and for all, and makes the editor autocomplete summary.openHours with its description alongside. In a six-layer project that is worth more than any external document, because it lives next to the code and is updated with it.
A word on dosage: document the exported functions with JSDoc —the public surface of each module— and leave the internal ones with a good name. Documenting everything produces files with more comment than code, and nobody reads any of it.
// @ts-check: type checking without TypeScript
// @ts-check: type checking without TypeScriptHere is the next step up, and you can climb it without rewriting anything. The TypeScript compiler can analyze .js files using the information in JSDoc. It is switched on with a comment on the first line:
// @ts-check
import { Task } from './task.js';
/** @param {number} id */
export function find(id) { … }
find('7');
// ~~~ Argument of type 'string' is not assignable to parameter of type 'number'.That warning is exactly case 1 from the previous lesson, caught in the editor, without running anything, without opening the browser and without Marta having to report a thing. A bug that cost a full investigation would have been a red underline while it was being typed.
To switch it on across the whole project without adding the comment file by file:
// jsconfig.json
{
"compilerOptions": {
"checkJs": true,
"strict": true,
"target": "esnext",
"module": "esnext",
"moduleResolution": "bundler",
"noEmit": true
},
"include": ["js/**/*.js"]
}"noEmit": true is the essential part: TypeScript generates no files at all, it only checks. Your code stays runnable JavaScript exactly as it is, served straight to the browser.
The honest comparison:
JavaScript + JSDoc + @ts-check |
TypeScript | |
|---|---|---|
| Compilation needed | No | Yes |
| Type coverage | Good, somewhat limited in advanced cases | Complete |
| Verbosity | High (long comments) | Low (native syntax) |
| Adoption cost | Very low, file by file | Medium to high |
Rules like no-floating-promises |
Available with the typed plugin | Available |
For Nómada Tasks, @ts-check is the sensible option: zero changes to deployment and a net that catches the whole family of type bugs. Full TypeScript is a project-level decision covered in Next Steps, where it is placed within the complete map of what comes after this course.
- Quality metrics, used sensibly
There are numeric quality metrics, and two are worth knowing.
Cyclomatic complexity. It counts the independent execution paths through a function: 1 as a baseline, +1 for each if, else if, case, for, while, catch, &&, || and ?:. It is, almost literally, the minimum number of tests needed to cover every branch, and that is why it matters here.
// Complexity 1: a single path
export function statusBadge(status) {
return BADGES[status] ?? '?';
}
// Complexity 5: four decisions + the baseline
function validateForm(form, data, today) {
const errors = [];
for (const field of form.elements) { // +1
if (field.willValidate && !field.checkValidity()) { // +1 (if) +1 (&&)
errors.push({ field, message: field.validationMessage });
}
}
if (data.dueDate < today) errors.push(…); // +1
if (data.tags.length > 5) errors.push(…); // +1
return errors;
}ESLint measures it with the complexity rule:
rules: {
complexity: ['warn', { max: 10 }],
'max-depth': ['warn', 4], // block nesting
'max-lines-per-function': ['warn', { max: 60, skipComments: true, skipBlankLines: true }]
}As a rough guide: below 10, comfortable; between 10 and 20, look at whether it can be split; above 20, there are almost certainly two functions in there.
Technical debt. It is the metaphor, not a metric: today's shortcuts are paid back with interest tomorrow in the form of development time. Some tools express it in estimated hours to fix. That number is an estimate of an estimate; it is useful for comparing how one project evolves over time, not for comparing projects and not for showing off.
And the warning that gives this section its title: do not fall into number fetishism. Three real pathologies:
- Chasing the metric instead of the goal. Lowering complexity by splitting a function into four incoherent pieces makes the code worse and the number better.
- Confusing "no warnings" with "done well". Case 3 in 08-01 —
openHoursadding up to 48 instead of 45— passed every ESLint rule. A clean lint says nothing about whether the program is correct. - Using metrics to evaluate people. As soon as a number becomes a target, it stops being a good measure (Goodhart's law). Metrics are a thermometer for the code, not a grade.
- Nómada Tasks: setting it all up and fixing the warnings
Let us assemble the complete configuration and see what it finds in the real code.
npm install --save-dev eslint @eslint/js globals eslint-config-prettier prettier husky lint-staged
npx husky init// eslint.config.js — the project's complete configuration
import js from '@eslint/js';
import globals from 'globals';
import prettier from 'eslint-config-prettier';
export default [
{ ignores: ['node_modules/**', 'coverage/**', 'dist/**'] },
js.configs.recommended,
// The application: browser + ES modules
{
files: ['js/**/*.js'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: { ...globals.browser }
},
rules: {
// — Correctness —
eqeqeq: ['error', 'always', { null: 'ignore' }], // allows `x == null` (null and undefined at once)
'no-undef': 'error',
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'no-implicit-globals': 'error',
'require-await': 'error',
'no-cond-assign': ['error', 'always'],
'no-constant-condition': 'error',
'no-fallthrough': 'error',
// — Hygiene —
'no-debugger': 'error', // ← the 08-01 one
'no-console': ['warn', { allow: ['warn', 'error'] }],
'prefer-const': 'error',
'no-var': 'error',
'object-shorthand': 'warn',
// — Size —
complexity: ['warn', { max: 12 }],
'max-depth': ['warn', 4]
}
},
// The service worker: different global, different rules
{
files: ['sw.js'],
languageOptions: { globals: { ...globals.serviceworker } },
rules: { 'no-console': 'off' } // in a SW, the console is the only window
},
// Tooling that runs in Node
{
files: ['*.config.js', 'scripts/**/*.js'],
languageOptions: { sourceType: 'module', globals: { ...globals.node } },
rules: { 'no-console': 'off' }
},
prettier // ← last, always
];And the first run over the code from Modules 1 to 7:
$ npm run lint
/nomada-tasks/js/view/board-view.js
14:10 error 'WEIGHTS' is defined but never used no-unused-vars
96:5 warning Unexpected console statement no-console
/nomada-tasks/js/model/board.js
52:9 error Unexpected 'debugger' statement no-debugger
/nomada-tasks/js/data/tasks-api.js
61:9 error Expected '===' and instead saw '==' eqeqeq
88:1 error Async method 'deleteTask' has no 'await' require-await
/nomada-tasks/js/view/controller.js
38:15 error 'task' is not defined no-undef
/nomada-tasks/sw.js
23:3 warning Unexpected console statement no-console
✖ 7 problems (5 errors, 2 warnings)
1 error and 0 warnings potentially fixable with the `--fix` option.Let us go one by one, because every fix has its nuance:
1 · 'WEIGHTS' is defined but never used. An orphan import from a refactor. Delete the line. Cost: zero. Benefit: whoever reads the file will not go hunting for where a weight that is no longer used gets used.
2 · Unexpected 'debugger' statement. The debugger from the previous lesson, on its way to the repository. This is the warning that pays for the whole configuration on its own: a debugger in production freezes the application for anyone with DevTools open.
3 · Expected '===' and instead saw '=='.
// Before
if (response.status == 204) return null;
// After
if (response.status === 204) return null;There was no real bug here (status is always a number), but this is one of those rules that does not admit case-by-case exceptions: keeping == in the code forces you to reason every single time about whether the coercion is safe. --fix corrects it on its own.
4 · Async method 'deleteTask' has no 'await'. This one is a genuine bug:
// Before — the async is a lie: nobody catches the network error here
export async function deleteTask(id) {
fetch(buildUrl(`/tasks/${id}`), { method: 'DELETE' }); // ← floating promise
return true; // ← always a lie
}
// After
export async function deleteTask(id) {
const response = await fetch(buildUrl(`/tasks/${id}`), { method: 'DELETE' });
await check(response);
return true;
}The original version returned true before the server answered. If the delete failed with a 403, the interface removed the card anyway and the error was lost as an unhandledrejection. A three-word rule has found a data-consistency bug.
5 · 'task' is not defined. A variable that was renamed in half of its occurrences:
// Before
const clickedTask = board.findById(id);
if (task.status === 'done') return; // ← 'task' no longer exists: ReferenceErrorThis code threw a ReferenceError at runtime, but only on the branch that is almost never taken. ESLint sees it without running anything. It is the perfect example of why no-undef deserves the error level.
6 and 7 · no-console. Debug logs from 08-01. The service worker ones are allowed by layer 3 (there, the console is the only window). The view ones are replaced by the log() from exercise 2 of the previous lesson, which respects levels and does not clutter the console in production.
Result after the fixes:
$ npm run lint && npm run format:check
Checking formatting...
All matched files use Prettier code style!Five errors, of which two were real bugs nobody had noticed: a delete that lied about its result and a latent ReferenceError. Neither would have turned up in a quick review, and both cost less than a minute of configuration.
Common Mistakes and Tips
- Switching on hundreds of rules at once in an existing project. Out come 400 warnings, nobody looks at them and the tool loses all credibility. Start with
recommendedplus the ten rules from section 7, leave the rest atwarnand raise the bar once the floor is clean. - Forgetting
eslint-config-prettier, or putting it before your own rules. It goes last in the array. If it goes earlier, your formatting rules override it and the save-format-complain loop comes back. - Not configuring
globalsper environment. Without it,no-undefproduces false positives insw.js(withselfandcaches) and in the test files (withdescribeandexpect), and the typical reaction —switching the rule off— disarms the most useful detector you have. - Forgetting the extension in your
importstatements. Bundlers forgive it; the browser from 05-04 does not. Switch it on withimport/extensionsset to'always'. // eslint-disable-next-linewith no rule name and no reason. It switches off every rule on that line, future ones included, and nobody will know whether it is still needed.- Mixing the formatting commit with logic changes. The diff becomes unreadable and review becomes impossible. Formatting in an isolated commit, and its hash in
.git-blame-ignore-revs. - Git hooks so slow that people use
--no-verify. Thepre-commithook should only look at staged files. The full test suite goes topre-pushor to CI. - Believing that a clean lint means the code works. Case 3 from 08-01 passed every rule. Static analysis checks form; behavior is checked with tests, and that starts in the next lesson.
- Tip: pin your versions and use
npm ciin CI. An automatic ESLint update can switch on new rules and turn a repository nobody has touched red. - Tip:
npx eslint . --max-warnings=0turns warnings into errors for CI. It is how you stopwarnentries piling up without having to promote them all toerrorin the editor. - Tip: write the conventions into
CONTRIBUTING.md. What is not written down gets argued about in every review; what is written down gets cited in one line.
Exercises
Exercise 1 — A complete per-environment configuration.
Write the eslint.config.js for Nómada Tasks covering five different environments: (a) js/**/*.js as browser ES modules; (b) sw.js as a service worker; (c) **/*.test.js with the Jest globals and the test plugin's rules; (d) cypress/**/*.js with cy, Cypress, describe and it as read-only globals; (e) the configuration files at the root, which run in Node. Justify in comments why each environment needs its own layer and what false positive it avoids.
Exercise 2 — Catching bugs with static analysis.
For each of these fragments, state which rule detects it, whether --fix can repair it, and what the correct fix is:
// A
export async function sync(board) {
repository.save(board);
return { ok: true };
}
// B
function nextStatus(status) {
switch (status) {
case 'pending':
return 'in-progress';
case 'in-progress':
log('info', 'closing');
case 'done':
return null;
}
}
// C
if (task.estimatedHours = 0) {
throw new ValidationError('Invalid hours', 'estimatedHours', 0);
}
// D
const open = board.openTasks;
const closed = board.tasks.filter((t) => t.status == 'done');
return closed.length;Exercise 3 — Document the module with JSDoc and switch on @ts-check.
Take js/model/board.js and add complete JSDoc documentation: a @typedef for BoardSummary with its six fields, types for every public method (add, changeStatus, filter, summary, hoursByAssignee), @throws where appropriate and an @example with the canonical backlog numbers. Switch on // @ts-check in the file and describe which error the editor would flag in each of these three incorrect uses: board.add({ id: 7, title: 'X' }), board.changeStatus('3', 'done') and board.summary().
Solutions
Solution 1
// eslint.config.js
import js from '@eslint/js';
import globals from 'globals';
import prettier from 'eslint-config-prettier';
import jest from 'eslint-plugin-jest';
export default [
{ ignores: ['node_modules/**', 'coverage/**', 'dist/**', 'cypress/videos/**', 'cypress/screenshots/**'] },
js.configs.recommended,
// (a) The application. Browser globals: without them, `document` and `fetch`
// would trigger no-undef in every view and data file.
{
files: ['js/**/*.js'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: { ...globals.browser }
},
rules: {
eqeqeq: ['error', 'always', { null: 'ignore' }],
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'no-implicit-globals': 'error',
'require-await': 'error',
'no-debugger': 'error',
'no-console': ['warn', { allow: ['warn', 'error'] }],
'prefer-const': 'error',
'no-var': 'error',
complexity: ['warn', { max: 12 }]
}
},
// (b) Service worker. A different global: `self`, `caches` and `clients` exist;
// `document` does NOT. Declaring it here means an accidental use of
// `document` in the SW does get flagged as an error, which is what we want.
{
files: ['sw.js'],
languageOptions: { sourceType: 'module', globals: { ...globals.serviceworker } },
rules: { 'no-console': 'off' }
},
// (c) Tests. Without globals.jest, `describe`, `test` and `expect` would be no-undef
// in every file, and the typical reaction (turning no-undef off) would disarm the rule.
{
files: ['**/*.test.js', 'test/**/*.js'],
plugins: { jest },
languageOptions: { globals: { ...globals.jest, ...globals.node } },
rules: {
'jest/no-focused-tests': 'error', // a forgotten test.only leaves the suite unrun
'jest/no-disabled-tests': 'warn',
'jest/expect-expect': 'error',
'jest/valid-expect': 'error',
'no-console': 'off' // in a test, the odd console call is acceptable
}
},
// (d) Cypress. `cy` and `Cypress` are globals injected by the runner;
// besides, the E2E test code runs in the browser.
{
files: ['cypress/**/*.js'],
languageOptions: {
globals: {
...globals.browser,
cy: 'readonly',
Cypress: 'readonly',
describe: 'readonly',
it: 'readonly',
beforeEach: 'readonly',
expect: 'readonly'
}
},
rules: { 'no-unused-expressions': 'off' } // the .should() style triggers it falsely
},
// (e) Configuration and scripts: they run in Node, not in the browser.
// `process` and `console` are legitimate here and are not in js/**.
{
files: ['*.config.js', 'scripts/**/*.js'],
languageOptions: { sourceType: 'module', globals: { ...globals.node } },
rules: { 'no-console': 'off' }
},
prettier
];Solution 2
| Case | Rule | --fix? |
Fix |
|---|---|---|---|
| A | require-await |
No | The function is async with no await: either the async is superfluous, or an await is missing. Here it is missing: await repository.save(board) if it is asynchronous, or drop the async. The real bug is that { ok: true } is returned with no guarantee that anything was saved |
| B | no-fallthrough |
No | After log(...) a return or a break is missing: 'in-progress' falls into 'done' and returns null instead of the next status. There is also no default (rule default-case) |
| C | no-cond-assign |
No | It is = instead of ===. The condition assigns 0 to estimatedHours (going through the setter, which would in turn throw for R3) and evaluates to 0, which is falsy: the validation never fires. Correct: if (task.estimatedHours === 0) |
| D | eqeqeq + no-unused-vars |
Yes for eqeqeq |
t.status == 'done' → ===; and open is declared and never used: delete the line |
// A · fixed
export async function sync(board) {
await repository.save(board);
return { ok: true };
}
// B · fixed
function nextStatus(status) {
switch (status) {
case 'pending':
return 'in-progress';
case 'in-progress':
log('info', 'closing');
return 'done';
case 'done':
return null;
default:
throw new ValidationError(`Unknown status: "${status}".`, 'status', status);
}
}
// C · fixed
if (task.estimatedHours === 0) { … }
// D · fixed
return board.tasks.filter((t) => t.status === 'done').length;Solution 3
// @ts-check
import { Task } from './task.js';
import { ValidationError } from './errors.js';
/**
* Aggregate figures for the board on a reference date.
*
* @typedef {object} BoardSummary
* @property {number} total Every task on the board
* @property {number} open The ones that are not in the 'done' status
* @property {number} totalHours Sum of estimatedHours across all of them
* @property {number} openHours Sum of estimatedHours across the open ones
* @property {number} overdue Open tasks with a dueDate in the past (R10)
* @property {number} effort Sum of hours × priority weight
*/
export class Board {
/** @type {Task[]} */
#tasks = [];
/**
* @param {string} name
* @param {Task[]} [tasks=[]] Added one by one, applying R1
*/
constructor(name, tasks = []) { … }
/**
* Adds a task to the board.
* @param {Task} task A Task instance, not a plain object
* @returns {Board} the board itself, for chaining
* @throws {ValidationError} if it is not a Task, or if the id already exists (R1)
*/
add(task) { … }
/**
* Applies a status transition to a task on the board.
* @param {number} id The task's numeric identifier
* @param {'pending'|'in-progress'|'done'} next
* @returns {Board}
* @throws {ValidationError} if the task does not exist or the transition violates R6
*/
changeStatus(id, next) { … }
/**
* @param {(task: Task) => boolean} predicate
* @returns {Task[]} a new array; the board is not modified
*/
filter(predicate) { … }
/**
* @param {string} today Reference ISO date, 'yyyy-MM-dd'
* @returns {BoardSummary}
*
* @example
* const board = new Board('Taller Nómada', createBacklog());
* board.summary('2026-09-20');
* // { total: 6, open: 5, totalHours: 48,
* // openHours: 45, overdue: 1, effort: 124 }
*/
summary(today) { … }
/**
* Open hours grouped by person. Tasks with no assignee (R8)
* are grouped under the key 'unassigned'.
* @returns {Record<string, number>}
*
* @example
* board.hoursByAssignee(); // { Iván: 25, Lucía: 14, Marta: 6 }
*/
hoursByAssignee() { … }
}The three errors the editor would flag with @ts-check:
1) board.add({ id: 7, title: 'X' })
Argument of type '{ id: number; title: string; }' is not assignable to
parameter of type 'Task'. Type is missing the following properties: status,
isOpen, effort, changeStatus…
→ It is exactly what R1 checks at runtime, but before anything runs.
2) board.changeStatus('3', 'done')
Argument of type 'string' is not assignable to parameter of type 'number'.
→ Case 1 from 08-01 (the id that arrived as a string), caught in the editor.
3) board.summary()
Expected 1 arguments, but got 0.
→ With no date, `isOverdue` would receive undefined and `overdue` would silently
come out as 0: a mute bug, of the same kind as the miscounted counter in case 3.Conclusion
Nómada Tasks no longer depends on individual discipline. You know what a static analyzer is and where its boundary lies: it can assert that a variable is unused, that an identifier does not exist, that an async awaits nothing or that there is an assignment inside an if; it cannot know whether openHours should add up to 45 or 48. That is why static analysis and tests do not compete: they cover different families of bugs, and a serious project has both.
You have ESLint properly configured: a flat, layered eslint.config.js with files delimiting each environment —the application in the browser, the service worker with its own global, Node for the tooling—, globals correctly declared so that no-undef is a real detector and not a source of false positives, the three levels (off/warn/error) used with judgment, and the ten rules that prevent real bugs: no-unused-vars, no-undef, eqeqeq, no-implicit-globals, require-await, no-fallthrough, no-cond-assign, no-debugger and the rest. You know how to silence a rule by naming it and justifying it, you know what --fix repairs and what it does not —form yes, intent never—, and you know the plugins that add value: imports (with import/extensions at 'always', essential in the browser, and import/no-cycle to protect the 05-04 graph), tests (with no-focused-tests catching the forgotten test.only), and why the JSX accessibility plugin does not fit a plain-DOM project.
You have Prettier as a deterministic formatter and you understand why it does not compete with ESLint but splits the work with it: one answers "is it well printed?" and the other "is it correct?". You know how to combine them without loops by putting eslint-config-prettier last, how to adopt it in an isolated commit and how to neutralize it in git blame. And you have the three layers of defense in place: the editor formatting and fixing on save, a pre-commit hook with Husky and lint-staged that only looks at staged files so that nobody is tempted by --no-verify, and a GitHub Actions workflow with npm ci, npm run lint and npm run format:check that nobody can dodge, ready to receive the npm test step from the next lesson.
And you have what no tool can give you: the conventions. Names that document (openHours, isOverdue(), LocalRepository, #status) and the anti-names to stamp out; the criterion for function size —if you need a comment to separate two parts, they are two functions—; comments that explain the why rather than repeating the what; JSDoc documenting the public surface of each module with @typedef BoardSummary capturing the canonical numbers; // @ts-check with jsconfig.json to catch the id that arrives as a string while you are typing it, with no compilation and no change to deployment; and metrics —cyclomatic complexity, technical debt— used as a thermometer and never as a target. The real run found seven problems and two of them were genuine bugs: a deleteTask that returned true without waiting for the server, and a latent ReferenceError on a rarely taken branch.
But notice what none of those seven lines mentioned: that the board summary must come to 45 open hours out of 48, that the done → in-progress transition is forbidden by R6, that a task with no title must throw a ValidationError, that the canonical backlog has a weighted effort of 124. That is not form, it is behavior, and no static rule can check it: you have to run the code with known inputs and compare the output with what you expect. That is an automated test, and that is where the three debts you wrote down in the previous lesson are heading. In Unit Testing with Jest you will set up the test runner, write the complete battery for Task and Board —and discover that all that insistence in 03-03 on pure functions, and that clean boundary between model and view you have been maintaining for six modules, were from the very beginning what was going to make it possible to test everything without opening a browser.
JavaScript Course: From Beginner to Advanced
Module 1: Introduction to JavaScript
- What Is JavaScript?
- Setting Up Your Development Environment
- Your First JavaScript Program
- JavaScript Syntax and Basic Concepts
- Variables and Data Types
- Basic Operators
- Type Conversion and Comparisons
- The Course Project: Nómada Tasks
Module 2: Control Structures
- Conditional Statements
- Loops: for, while, do-while
- Switch Statements
- Flow Control: break, continue and Nested Loops
- Error Handling with try-catch
Module 3: Functions
- Defining and Calling Functions
- Function Expressions and Arrow Functions
- Parameters and Return Values
- Scope and Closures
- Hoisting and the Execution Context
- Higher-Order Functions
- Recursion
Module 4: Objects and Arrays
- Introduction to Objects
- Object Methods and the
thisKeyword - Arrays: Basics and Methods
- Iterating over Arrays
- Searching, Sorting and Aggregating Data: find, sort and reduce
- Array Destructuring
- Object Destructuring, Spread and Rest
- JSON and Copying Objects
Module 5: Advanced Objects and Functions
- Prototypes and Inheritance
- Classes and Object-Oriented Programming
- Encapsulation: Getters, Setters and Private Fields
- Modules: Import and Export
- Asynchronous JavaScript: Callbacks
- Promises and Async/Await
- The Event Loop and the Microtask Queue
- Iterators and Generators
Module 6: The Document Object Model (DOM)
- Introduction to the DOM
- Selecting and Manipulating DOM Elements
- Handling Events
- Propagation, Delegation and Custom Events
- Creating and Removing DOM Elements
- Rendering Lists and HTML Templates
- Handling and Validating Forms
Module 7: Browser APIs and Advanced Topics
- Local and Session Storage
- The Fetch API and AJAX
- Robust Requests: Errors, Timeouts and AbortController
- WebSockets
- Service Workers and Progressive Web Apps (PWAs)
- Essential Browser APIs
- Introduction to WebAssembly
Module 8: Testing and Debugging
- Debugging JavaScript
- Code Quality: ESLint, Prettier and Conventions
- Unit Testing with Jest
- Test Doubles: Mocks, Stubs and Spies
- Integration Testing
- End-to-End Testing with Cypress
Module 9: Performance and Optimization
- Measure Before You Optimize: DevTools and Web Vitals
- Optimizing JavaScript Performance
- Memory Management
- Efficient DOM Manipulation
- Lazy Loading and Code Splitting
Module 10: JavaScript Frameworks and Libraries
- Why Frameworks Exist
- Introduction to React
- State Management with Redux
- Vue.js Basics
- Angular Basics
- Choosing the Right Framework
