● David R. Longnecker
Chapter 4 · Foundations

Packages & Monorepos

No serious JavaScript or TypeScript project is written from a blank slate. You'll pull in code other people wrote, publish your own code for other parts of a project to use, and eventually organize several related pieces of a single project so they can share code without copy-pasting it. That's what this chapter is about: packages, and the repositories that hold many of them at once.

Two different things share the word "package"

This chapter uses project consistently for the thing you're building — your todo app, your own repository, the software you ship. Package is reserved for the narrower, technical sense: a single reusable, versioned unit of code with a package.json, whether it's something you installed from npm or something your own project publishes internally. Your project might contain several packages (that's exactly what a monorepo is, later in this chapter), but "package" never means "the whole app" in this course.

What a package is #

An npm package is just a folder of code with a package.json file describing it: a name, a version, an entry point, and — critically — a list of other packages it needs to run. Packages are published to and installed from a registry, most commonly npm, using a package manager such as npm itself, Yarn, or pnpm — the examples in this chapter use pnpm's workspace syntax, but the underlying ideas are the same across all three.

a minimal package.json
{
  "name": "todo-app",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "test": "vitest"
  },
  "dependencies": {
    "vue": "^3.4.0"
  },
  "devDependencies": {
    "vite": "^5.0.0",
    "typescript": "^5.4.0"
  }
}

dependencies are packages your code needs at runtime — a UI framework, a date library. devDependencies are packages only needed while building or testing — the bundler, the type checker, the test runner. Both get installed locally, but only dependencies conceptually matter to someone consuming your published package.

Whether you can actually strip devDependencies before running the built app in production depends on how it runs, not just this distinction: a frontend that Vite bundles into static files needs nothing but the output at runtime, but a server run directly with something like tsx or ts-node still needs that tool present, even though it's a devDependency. Don't assume "not a dependency" means "safe to prune" without checking how your specific build and deploy step actually works.

Semantic versions and lockfiles #

Package versions follow semantic versioning (semver): MAJOR.MINOR.PATCH. A patch bump is a bug fix, a minor bump adds functionality without breaking anything, and a major bump is allowed to break things. The ^ in "vue": "^3.4.0" means "any version compatible with 3.4.0 up to, but not including, 4.0.0" — you accept minor and patch updates automatically, but not a major version that might break your code.

That range is a moving target, though — two people running install a week apart could get different patch versions. A lockfile fixes this by recording the exact resolved version of every package, including the dependencies of your dependencies, so every install produces an identical set of files. You commit the lockfile to version control; you don't hand-edit it.

Tip

Always commit your lockfile. It's the difference between "works on my machine" and "works on every machine, every time, including your CI pipeline."

Why put multiple packages in one repository #

Consider a small project with three logical parts: a web client, a server, and a set of shared TypeScript types both of them need to agree on (what a "todo" object looks like, what an API response shape is). You could publish the shared types as its own versioned package on a registry, bump it, and update both consumers — but for code that only your own project uses, that's a lot of ceremony for every small change.

A monorepo keeps multiple related packages in one repository instead. Each is still its own package with its own package.json, but they live side by side and can reference each other directly, without a publish step in between.

A folder tree with apps and packages directories, where web and server both depend on shared apps/web Vue client apps/server HTTP API packages/shared types + utilities depends on depends on
Both apps import from the shared package directly. Change a type once in packages/shared and every consumer sees it immediately.

Workspace configuration #

Package managers support this pattern natively through workspaces: a top-level config listing glob patterns for where packages live. Installing once at the repo root resolves dependencies for every package, and links local packages to each other directly instead of downloading them.

workspace configuration, root of the repo
# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"

With that in place, apps/web/package.json can depend on the shared package by name, and the package manager resolves it to the local folder instead of a registry download:

apps/web/package.json (excerpt)
{
  "dependencies": {
    "@app/shared": "workspace:*"
  }
}

The workspace:* version marker means "always use whatever version of this package exists locally in the workspace," sidestepping the publish-and-bump cycle entirely for internal code. This particular syntax is pnpm's; npm and Yarn workspaces solve the same problem but with slightly different marker syntax, so don't copy it verbatim into a project using a different package manager.

Running scripts in dependency order #

A monorepo introduces a new problem a single package never has: build order. If apps/web imports types from packages/shared, and shared needs to be compiled to plain JavaScript before anything can import it, you must build shared first, every time, without remembering to do it manually.

a root script that builds in the right order
{
  "scripts": {
    "build": "pnpm --filter @app/shared build && pnpm --filter @app/web build && pnpm --filter @app/server build"
  }
}

pnpm can also figure out that order itself: pnpm -r build ("recursive") reads every package's declared workspace:* dependencies and builds them in the correct order automatically, without a hand-written chain of &&. Larger monorepos, or ones using a different package manager, often reach for a dedicated task runner instead, for caching and finer control — but the principle is the same either way: nothing that depends on shared should build before shared itself has produced its output.

Common mistake

Editing a file in packages/shared and seeing no effect in apps/web almost always means shared wasn't rebuilt. If your workspace consumes compiled output rather than source directly, a stale dist/ folder is one of the most common "why isn't my change showing up" bugs in a monorepo.

Setting up a real project #

Everything above describes the shape of a project. The rest of this course assumes you actually have one to work in, and setting one up is a short, one-time sequence.

1. Confirm Node.js is installed. Node is the JavaScript runtime every tool in this course runs on top of — pnpm, Vite, TypeScript's own compiler, all of it:

node --version

If that fails, install Node from nodejs.org first; everything else here assumes it's already available.

2. Install pnpm. Modern Node ships a tool called Corepack that installs pnpm for you, with no separate global package to manage:

corepack enable
corepack prepare pnpm@latest --activate

If corepack isn't available, npm install -g pnpm works too — that just means using npm once, to install pnpm itself.

3. Scaffold a project. Rather than hand-assembling every file this chapter has shown, use Vite's own project generator, which produces a working starter — a package.json, a tsconfig.json, a vite.config.ts, and a minimal component — in one command:

pnpm create vite@latest my-project -- --template vue-ts
cd my-project
pnpm install

pnpm create vite@latest downloads and runs Vite's generator; --template vue-ts picks the Vue-plus-TypeScript starting point this course builds toward. pnpm install then reads the generated package.json and downloads everything it lists, writing the lockfile discussed earlier in this chapter in the process.

Confirm it worked

pnpm dev starts a local dev server (Chapter 5 covers exactly what that is) and prints a URL, typically http://localhost:5173. Open it and confirm you see a working starter page, then stop the server with Ctrl+C. Keep this project around — Chapter 5's Try It picks up exactly where this leaves off.

Try it

Part 1: your first type check. In the project you just created, add a new file src/practice.ts with the Todo interface and summarize function from Chapter 3:

interface Todo {
  id: number;
  title: string;
  done: boolean;
  notes?: string;
}

function summarize(todo: Todo): string {
  return `${todo.title} (${todo.done ? "done" : "pending"})`;
}

const t: Todo = { id: 1, title: "Buy milk", done: false };
console.log(summarize(t));

A standalone .ts file isn't served on its own — add one line to the top of src/main.ts so it actually runs:

import "./practice";

Run pnpm dev and open http://localhost:5173 in a browser, then open its developer console (F12, or right-click → Inspect → Console). You'll see Buy milk (pending) logged there — that's practice.ts executing.

Now watch the type checker work: back in practice.ts, delete the ? from notes, then try calling summarize({ id: 3, title: "x", done: false }) — still no error. Now add todo.notes.length somewhere in the function body without a null check and watch your editor flag it in red before you ever save the file. That's the vue-ts template's strictNullChecks, doing its job on code you wrote yourself.

Part 2: exploring your dependencies. In that project, run each of these and look at what it shows you:

pnpm list              # every dependency this project resolves, direct and transitive
pnpm list --depth 0     # just the direct, top-level dependencies from package.json
pnpm why vue            # which of your dependencies actually needs "vue," and why

Then open pnpm-lock.yaml in your editor and skim the first screen — that's the lockfile this chapter described, in the flesh. Now add one real dependency you don't have yet:

pnpm add lodash-es

Look at package.json and pnpm-lock.yaml again. package.json gained exactly one line, under dependencies. The lockfile recorded far more: the exact resolved version of lodash-es itself, plus every dependency it needed, none of which you asked for by name — this is the "dependencies of your dependencies" this chapter mentioned, made visible.

Why this matters for everything that follows #

The projects you build in Chapters 6 onward — a frontend, a backend, and shared types between them — are exactly the three-package shape from this chapter's diagram. When you later see a build fail because "shared hasn't been built yet," or an import that resolves to a local folder instead of a registry download, you'll recognize it as the workspace mechanism described here, not a mystery.