Appearance
The Modern JavaScript Toolchain
JavaScript tooling feels unusually crowded because several independent jobs are often bundled behind one command. A useful mental model assigns each tool a responsibility:
- The runtime executes JavaScript.
- The package manager resolves and installs dependencies.
- The type checker analyzes TypeScript relationships.
- The linter analyzes suspicious or inconsistent source patterns.
- The formatter chooses source layout.
- The test runner executes verification code.
- The development server serves source and coordinates fast updates.
- The bundler transforms a module graph into deployable assets.
Node and Bun combine several roles, as do framework CLIs, but the underlying distinctions remain useful when diagnosing a failure.
Package metadata is the project entry point
A small browser project might declare:
json
{
"name": "transaction-dashboard",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"check": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run",
"build": "vite build"
},
"dependencies": {
"react": "...",
"react-dom": "..."
},
"devDependencies": {
"eslint": "...",
"typescript": "...",
"vite": "...",
"vitest": "..."
}
}Scripts provide stable project commands independent of globally installed binaries. Runtime packages belong in dependencies; build, checking, and testing tools usually belong in devDependencies. Applications should commit one lockfile produced by their selected package manager so installations resolve consistently.
Node with npm is the conservative baseline because documentation and package compatibility are broad. pnpm and Yarn are alternative package managers with different storage and workspace behavior. Bun combines a runtime, package manager, test runner, and bundling capabilities. Choose deliberately, avoid mixing lockfiles, and do not assume package-manager choice changes JavaScript semantics.
Version ranges and lockfiles answer different questions
A manifest range such as ^4.2.1 describes releases the project is willing to accept when resolving dependencies. For a stable major version, the caret normally permits later minor and patch releases below 5.0.0. Tilde ranges are narrower, exact versions narrower still, and prerelease or zero-major ranges require careful reading rather than intuition.
The lockfile records the concrete dependency graph selected by one package-manager family, including transitive packages and integrity data. Committing it gives application development and automation a repeatable starting point. It does not guarantee that a future installation is risk-free, and manually pinning every manifest entry does not replace the transitive graph a lockfile captures.
Libraries and applications have different publication concerns. An application deploys its locked graph. A library declares compatible ranges that will be combined with each consumer's graph, so its continuous integration should test meaningful supported versions instead of only the author's lockfile.
Workspaces let several packages share one repository and installation while retaining separate manifests. They are useful for a library plus demo application or several independently built packages; they are overhead for a small application with no real package boundary.
Vite connects source modules to the browser
During development, Vite serves source through native ESM and transforms files on demand. It resolves package imports and provides hot module replacement, allowing affected modules to update without a full page reload.
For production, vite build follows the dependency graph and produces optimized static assets. This is where bundling, code splitting, asset hashing, and environment replacement happen.
Tree shaking is the bundler's attempt to omit exports that cannot be reached from an entry point. Static ESM imports make this analysis possible, but results depend on package metadata, side effects, and the bundler. A top-level registration, stylesheet import, or polyfill may be behavior even when none of its exports are referenced. Package authors should declare side-effect metadata accurately; application authors should inspect output rather than assuming an unused-looking import vanished.
Code splitting creates more than one output chunk so some code can load later. Dynamic import introduces a natural boundary:
js
async function openReportDesigner() {
const { mountReportDesigner } = await import("./report-designer.js");
return mountReportDesigner(document.querySelector("#designer"));
}This can reduce initial JavaScript, but it also adds a network and failure boundary. Handle loading and errors, avoid producing hundreds of tiny chunks, and measure whether the split improves the experience. A dynamic import is language syntax; chunk creation is bundler policy, while Node may simply load another module from disk.
Vite transforms TypeScript syntax but does not perform complete type checking. Keep tsc --noEmit separate. The same distinction applies to many fast build tools: successful transformation means the syntax could be emitted, not that the program satisfies its types.
Browser-facing environment variables are bundled into client code. Vite exposes selected values through import.meta.env and conventionally only exposes variables with its public prefix. Anything delivered to the browser is observable by users; never place server secrets there.
ESLint finds source-level problems
Modern ESLint configuration uses a JavaScript configuration array:
js
// eslint.config.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
"no-console": "off",
},
},
);React projects add the official or community plugins recommended by their chosen framework template. Some TypeScript rules require type information and are slower; enable them when their findings justify the cost.
A linter is strongest at detecting patterns: discarded promises, stale React effect dependencies, accidental fallthrough, unused variables, or unsafe TypeScript operations. Formatting rules create noisy configuration conflict, so many teams delegate layout to a formatter.
Prettier is an opinionated formatter. Other projects use Biome or a formatter built into another tool. The particular choice matters less than having one committed configuration and letting automation apply it. Do not hand-negotiate whitespace in review.
Tests occupy several layers
Node includes a test runner, while Vitest integrates closely with Vite projects. Jest remains common in established codebases. DOM component tests frequently use Testing Library, which encourages interaction through user-visible roles and labels.
Fast unit tests suit pure parsing and domain logic. Integration tests check modules together and may use a simulated DOM. Browser automation tools such as Playwright run the application in real browser engines and cover routing, layout-sensitive behavior, and complete user flows.
Use the cheapest layer that can observe the behavior. A formatting function does not need a browser; a login redirect may. Coverage is evidence of executed lines, not proof of meaningful assertions.
The preceding testing article develops executable examples and consumer-package checks. Tool choice should follow the boundary: Node's runner is often enough for domain code, a DOM environment helps component integration, and a real browser is necessary for browser-owned behavior.
Automation should reproduce one local command
A useful local gate is:
sh
npm run check
npm run lint
npm test
npm run buildContinuous integration should run the same scripts on a clean install. Avoid a CI-only chain of hidden commands that developers cannot reproduce. Git hooks can shorten feedback but should not be the only enforcement because hooks can be skipped or misconfigured.
Keep dependency updates routine and review lockfile changes. Package lifecycle scripts execute code during installation, so dependencies are part of the software supply chain. Prefer well-maintained direct dependencies, remove unused packages, and use the package manager's audit information as one signal rather than a complete security verdict.
Frameworks add conventions above the toolchain
React is a UI library, not by itself a router, data framework, bundler, or deployment platform. Frameworks add opinions about routing, server rendering, data loading, caching, server/client boundaries, and build output. Those capabilities can be valuable, but learn which layer provides each one.
Generated starter projects are the best source for compatible initial configuration because plugin APIs and recommended settings change. Read what the template created: scripts, module mode, TypeScript settings, lint configuration, and deployment target. Once understood, remove unnecessary parts rather than accumulating tools by habit.
When a command fails, classify it before changing configuration:
text
Can the runtime load the module?
Can the package manager resolve it?
Can the transformer parse it?
Does the type checker accept it?
Does the linter object to a pattern?
Does the test expose incorrect behavior?That classification is the durable skill. Individual tools will change; the responsibilities and boundaries they serve are much more stable.
Optional prompts
Predict: Can vite build succeed while tsc --noEmit fails on the same source tree? What does each result establish?
Answer: Yes. Vite can transform TypeScript syntax into runnable JavaScript without performing the full project type check. A successful bundle establishes that transformation and bundling completed;
tsc --noEmitseparately establishes that configured static relationships are accepted.
Explain: Why does "library": "^4.2.0" in a manifest solve a different problem from the lockfile's exact resolved version?
Answer: The range declares which releases the project is willing to resolve, while the lockfile records the concrete dependency graph selected for repeatable installation. Applications deploy a locked graph; published libraries must also consider how their ranges combine with each consumer's independently resolved graph.
Debug: A module exists in node_modules, the editor accepts its types, but Node throws an export-subpath error. Which layer failed first?
Answer: Runtime module resolution failed: the package is installed and types may be visible, but Node's loader rejected the requested path under the package's
exportscontract. Changing lint or formatting settings cannot fix it; use a public subpath or correct the package metadata.