Skip to content

Yarn Berry (JavaScript/TypeScript)

The ground-up TypeScript rewrite of Yarn (Yarn 2+, "Berry"): a plugin-based package manager whose workspaces field, first-class workspace: resolver protocol, Plug'n'Play install model, and yarn workspaces foreach topological runner make it one of the most complete native-workspace implementations in any ecosystem — and the closest existing template for the local-cross-reference protocol a dub [workspace] block would need.

FieldValue
LanguageTypeScript (Node.js >=18.12.0; the codebase is itself a Yarn workspace)
LicenseBSD-2-Clause
Repositoryyarnpkg/berry
Documentationyarnpkg.com · Workspaces feature page · workspace: protocol
CategoryJS/TS Package Manager
Workspace modelRoot-package workspace declared by a workspaces glob array in the root package.json; members linked via the workspace: protocol
First releasedYarn 2.0 ("Berry"), January 2020 (rewrite of Yarn 1 / "Classic")
Latest release4.15.0

Latest release: 4.15.0 (the 4.x "Yarn Modern" line, May 2026); the active development trunk read for this deep-dive is at @yarnpkg/cli 4.16.0. Yarn 4.0 (March 2024) reinforced Plug'n'Play (nodeLinker: pnp) as the default linker, shipped the JavaScript constraints engine, and introduced the catalog: protocol for centralized version pinning. All file paths below are quoted from the master checkout at ~/code/repos/typescript/berry.


Overview

What it solves

Yarn Berry is a Node.js package manager: it reads a package.json manifest, resolves the transitive dependency closure against the npm registry, writes a yarn.lock lockfile, and installs the result so that import/require resolve. But unlike Composer (which has no workspace model) or even npm (whose workspaces are a thinner layer), Berry treats the monorepo as the primary unit of work. A single repository declares a set of member packages ("workspaces") in its root package.json; those members can depend on each other through the dedicated workspace: protocol; the resolver links them locally with a symlink (LinkType.SOFT) instead of fetching from the registry; and a built-in plugin, plugin-workspace-tools, runs commands across the member graph in topological order with bounded concurrency and git-based change detection.

Berry's second defining bet is Plug'n'Play (PnP): rather than materialize a node_modules/ tree, it writes a single .pnp.cjs data file mapping every (package, version) to its on-disk location (a zip in a content-addressed cache) and patches Node's module resolver to consult that map. This eliminates the hoisting non-determinism and "phantom dependency" hazards of node_modules, and makes installs near-instant on a warm cache. PnP is optional — nodeLinker can be set to node-modules (classic hoisting) or pnpm (an isolated symlink store like pnpm) — but it is the default in Yarn 4.

Design philosophy

Berry is a plugin architecture wrapped around a small core. The core (@yarnpkg/core) owns the Project, Workspace, Manifest, Configuration, the resolver/fetcher/linker interfaces, and the lockfile; everything user-facing — the npm registry resolver, the workspace:/portal:/link:/patch:/catalog: protocols, the nm/pnp/pnpm linkers, even yarn workspaces foreach — is a plugin under packages/plugin-*. The workspace: protocol is itself just a Resolver implementation. From WorkspaceResolver.ts, the resolver that turns a workspace: range into a local, symlinked package:

ts
// packages/yarnpkg-core/sources/WorkspaceResolver.ts
async resolve(locator: Locator, opts: ResolveOptions) {
    const workspace = opts.project.getWorkspaceByCwd(locator.reference.slice(WorkspaceResolver.protocol.length) as PortablePath);
    return {
        ...locator,
        version: workspace.manifest.version || `0.0.0`,
        languageName: `unknown`,
        linkType: LinkType.SOFT,
        // ...
    };
}

The LinkType.SOFT is the crux: a workspace dependency is never copied or fetched, it is linked to its source directory, so an edit to a member is immediately visible to its dependents with no rebuild or re-install. The shouldPersistResolution() method returns false, so workspace resolutions are never written to the lockfile as fixed — they are re-derived from the live workspace graph on every install. This is the structural inverse of Composer's path repositories, which bolt local linking onto a registry-shaped resolver after the fact.

Within this catalog, Berry is the canonical JS/TS package manager with a first-class local-cross-reference protocol. Compare it against npm (workspaces but no workspace:-style range rewriting on publish), pnpm (also has workspace:, plus the isolated symlink store Berry borrows as nodeLinker: pnpm), and Bun (workspaces with a fast native installer). For where dub sits today, see the dub baseline and the D landscape.


How it works

The Project, the Workspace, and discovery

A Berry install begins with Project.find(), which walks up from the cwd to the directory containing the lockfile / root package.json, then recursively loads every workspace. The root workspace's package.json carries the workspaces field — an array of globs — and Workspace.setup() (Workspace.ts) expands them with fast-glob, recursing into each matched directory that contains a package.json:

ts
// packages/yarnpkg-core/sources/Workspace.ts
const patterns = this.manifest.workspaceDefinitions.map(
  ({ pattern }) => pattern,
);
if (patterns.length === 0) return;

const relativeCwds = await fastGlob(patterns, {
  cwd: npath.fromPortablePath(this.cwd),
  onlyDirectories: true,
  ignore: [`**/node_modules`, `**/.git`, `**/.yarn`],
});

The workspaces field is parsed in Manifest.ts, accepting either a bare array or the legacy { packages: [...] } object form:

ts
// packages/yarnpkg-core/sources/Manifest.ts
const workspaces = Array.isArray(data.workspaces)
  ? data.workspaces
  : typeof data.workspaces === `object` &&
      data.workspaces !== null &&
      Array.isArray(data.workspaces.packages)
    ? data.workspaces.packages
    : [];

Every workspace gets an anchoredLocator whose reference is workspace:<relativeCwd> — i.e. the workspace's own identity in the dependency graph is a workspace: locator keyed by its path relative to the project root. This is why the relative path is hashed for the identity (so the lockfile is OS-independent) and why two workspaces with the same name are a hard error (addWorkspace rejects a duplicate identHash).

NOTE

A nested workspace can itself declare a workspaces array, so the discovery is recursive — but the conventional layout is a flat packages/* (or apps/* + libs/*) under one root. The root package.json is almost always "private": true, because the root is a package (root-package-workspace model) but should never be publishable.

The workspace: protocol and publish-time range rewriting

A member depends on a sibling by writing a workspace: range in its dependencies:

json
{
  "name": "@acme/cli",
  "dependencies": {
    "@acme/greeter": "workspace:^"
  }
}

The selector after the colon controls how the range is rewritten when the package is published (yarn pack / yarn npm publish). During development all four forms resolve identically — to the local workspace — but the beforeWorkspacePacking hook in plugin-pack/sources/index.ts substitutes the real version so the published artifact has a normal, registry-resolvable range:

ts
// packages/plugin-pack/sources/index.ts — beforeWorkspacePacking (abridged)
// For workspace:path/to/workspace and workspace:* we look up the workspace version
if (
  structUtils.areDescriptorsEqual(
    descriptor,
    matchingWorkspace.anchoredDescriptor,
  ) ||
  range.selector === `*`
)
  versionToWrite = matchingWorkspace.manifest.version ?? `0.0.0`;
// For workspace:~ and workspace:^ we add the selector in front of the workspace version
else if (range.selector === `~` || range.selector === `^`)
  versionToWrite = `${range.selector}${matchingWorkspace.manifest.version ?? `0.0.0`}`;
else
  // for workspace:version we simply strip the protocol
  versionToWrite = range.selector;
Range written by authorResolves to (dev)Published as (if member version is 1.4.0)
workspace:*local workspace1.4.0 (exact)
workspace:~local workspace~1.4.0
workspace:^local workspace^1.4.0
workspace:^1.0.0local workspace^1.0.0 (protocol stripped)
workspace:packages/xlocal workspacethe member's exact version

This is the single most-imitated piece of Berry's design: it gives developers a local-first dependency that becomes a correct registry range at publish, with zero manual bookkeeping. pnpm adopted the same workspace: syntax; it is the direct model for the "Local Cross-Referencing Protocol" milestone in the dub proposal.

Plug'n'Play, the cache, and the linkers

After resolution, a linker materializes the install. The default (nodeLinker: pnp, from plugin-pnp/sources/index.ts) does not create node_modules/. Instead it writes a .pnp.cjs that encodes, for every package, the exact filesystem location of its dependencies, plus a Node loader (.pnp.loader.mjs) that intercepts module resolution. Package contents live as zip archives in a per-project (or global) content-addressed cache (.yarn/cache/), read in place via a zip filesystem layer (@yarnpkg/libzip / @yarnpkg/fslib).

ts
// packages/plugin-pnp/sources/index.ts — nodeLinker setting
nodeLinker: {
    description: `The linker used for installing Node packages, one of: "pnp", "pnpm", or "node-modules"`,
    type: SettingsType.STRING,
    default: `pnp`,
},

The three linkers are the three points on the dependency-isolation spectrum surveyed in concepts:

nodeLinker valueInstall shapeIsolation
pnp (default)one .pnp.cjs map + zipped packages in .yarn/cache/strict — only declared deps are resolvable
pnpmisolated symlink store (à la pnpm)strict — non-flat node_modules of symlinks
node-modulesclassic hoisted node_modules/ treeloose — phantom deps resolvable (compatibility)

PnP's strictness is the dependency-isolation payoff: a package can only require what it actually declared, because the .pnp.cjs map omits everything else — the "phantom dependency" class of bug is structurally impossible.

yarn workspaces foreach: the topological task runner

Task orchestration lives in plugin-workspace-tools. yarn workspaces foreach <cmd> (foreach.ts) selects a set of workspaces, then runs the sub-command across them, optionally in topological order and in parallel. The selection and scheduling flags are the heart of Berry's monorepo UX (detailed in dimension 5). The scheduler is a worklist loop: a workspace becomes runnable only once every workspace it depends on (via dependencies, plus devDependencies under --topological-dev) has left the pending set:

ts
// packages/plugin-workspace-tools/sources/commands/foreach.ts (abridged)
while (needsProcessing.size > 0) {
  if (report.hasErrors()) break;
  const commandPromises = [];
  for (const [identHash, workspace] of needsProcessing) {
    if (processing.has(workspace.anchoredDescriptor.descriptorHash)) continue;

    let isRunnable = true;
    if (this.topological || this.topologicalDev) {
      const resolvedSet = this.topologicalDev
        ? new Map([
            ...workspace.manifest.dependencies,
            ...workspace.manifest.devDependencies,
          ])
        : workspace.manifest.dependencies;
      for (const descriptor of resolvedSet.values()) {
        const depWorkspace = project.tryWorkspaceByDescriptor(descriptor);
        isRunnable =
          depWorkspace === null ||
          !needsProcessing.has(depWorkspace.anchoredLocator.locatorHash);
        if (!isRunnable) break;
      }
    }
    if (!isRunnable) continue;
    // ...dispatch under a pLimit(concurrency) gate...
  }
  if (commandPromises.length === 0) {
    // every remaining workspace is blocked → a cycle
    report.reportError(
      MessageName.CYCLIC_DEPENDENCIES,
      `Dependency cycle detected (...)`,
    );
    return;
  }
  await Promise.all(commandPromises);
}

Concurrency defaults to roughly half the available cores (Math.ceil(nodeUtils.availableParallelism() / 2)), is gated by p-limit, and is configurable via -j,--jobs (including -j unlimited). If no workspace is runnable yet the pending set is non-empty, that is by definition a dependency cycle, and Berry reports CYCLIC_DEPENDENCIES rather than deadlocking — a clean failure mode worth copying.


The five dimensions

1. Workspace Declaration & Topology

Root-package workspace, declared by a glob array. The root package.json carries workspaces: ["packages/*"] (or the legacy {"packages": [...]} object form). There is no separate workspace manifest file — the root package is the workspace root (contrast go.work, Cargo's virtual [workspace], or pnpm's separate pnpm-workspace.yaml). Discovery is by fast-glob, recursive (a member may declare its own workspaces), and always ignores node_modules, .git, and .yarn. Each member's identity in the graph is a workspace:<relativeCwd> locator; duplicate names are rejected.

json
{
  "name": "@acme/monorepo",
  "private": true,
  "workspaces": ["packages/*"]
}

There is no "virtual workspace" mode distinct from a "root package workspace" the way Cargo distinguishes them — the root is always a package, conventionally marked "private": true so it is never published. Topology (who-depends-on-whom) is derived from members' dependencies/devDependencies that point at sibling workspaces; Workspace.getRecursiveWorkspaceDependencies() and ...Dependents() walk that graph.

2. Dependency Handling & Isolation

Three selectable models via nodeLinker; default is content-addressed PnP. Cross-workspace local references use the workspace: protocol — a member is linked (LinkType.SOFT) to its sibling's source directory, never fetched, and the resolution is not persisted to the lockfile (shouldPersistResolution() === false), so it is always re-derived from the live graph. Selectors (*, ~, ^, explicit semver) control only publish-time range rewriting.

Third-party dependencies are isolated according to the linker: pnp (a .pnp.cjs map over zipped cache entries — strict, only declared deps resolvable), pnpm (isolated symlink store), or node-modules (hoisted, loose). PnP makes "phantom dependencies" structurally impossible. Berry also ships sibling local-link protocols in plugin-link:

ts
// packages/plugin-link/sources/constants.ts
export const PORTAL_PROTOCOL = `portal:`; // link a folder *with* its dependencies (full resolution)
export const LINK_PROTOCOL = `link:`; // link a raw folder *without* dependency resolution

portal: and link: cover out-of-tree local packages; workspace: is the in-tree case. The catalog: protocol (plugin-catalog) adds a workspace dependency registry: a root .yarnrc.yml catalog: map pins one version per dependency, and members write "typescript": "catalog:" to inherit it — the direct analogue of a Cargo [workspace.dependencies] table, and a model for the dub proposal's centralized-dependency milestone.

yaml
# .yarnrc.yml — the default catalog
catalog:
  typescript: ^5.9.2

3. Task Orchestration & Scheduling

A real topological DAG executor with bounded concurrency — but no input-hash result cache. yarn workspaces foreach -t run build builds the workspace dependency graph and runs each member's build script only after all of its workspace dependencies have finished (--topological uses dependencies; --topological-dev adds devDependencies). Execution is concurrent under a p-limit gate (default ≈ half the cores, -j unlimited to uncap), output is either buffered-per-process or -i,--interlaced in real time, and a non-empty-but-unrunnable worklist is reported as a CYCLIC_DEPENDENCIES error.

Change detection is git-ref-based affected detection, not content hashing: --since [ref] selects only workspaces whose files changed since a base ref, computed by fetchChangedWorkspaces (gitUtils.ts) via git merge-base + git diff --name-only + git ls-files --others, mapping each changed file back to its owning workspace and skipping the lockfile / cache / install-state / virtual folder:

ts
// packages/plugin-git/sources/gitUtils.ts (abridged)
const base = await fetchBase(root, {
  baseRefs:
    typeof ref === `string`
      ? [ref]
      : project.configuration.get(`changesetBaseRefs`),
});
const changedFiles = await fetchChangedFiles(root, {
  base: base.hash,
  project,
});
return new Set(
  miscUtils.mapAndFilter(changedFiles, file => {
    const workspace = project.tryWorkspaceByFilePath(file);
    // ...skip lockfile / cache / installStatePath / virtualFolder...
    return workspace;
  }),
);

IMPORTANT

Berry's --since answers "which workspaces changed?" and (with -R/--recursive) "what depends on them?", but it does not memoize task outputs. Unlike Turborepo or Nx, there is no per-task input hash and no "this build is already up to date, skip it" — foreach always runs the selected commands. Berry orchestrates; it does not cache build results.

4. Caching & Remote Execution

Package-install caching only; no task-output cache and no remote execution. Berry's cache (.yarn/cache/, or a global cache when enableGlobalCache: true) is a content-addressed store of resolved package archives (zips), keyed by (ident, version, cacheKey) and validated by checksum in yarn.lock. This makes re-installs and offline installs ("Zero-Installs" — commit the cache and .pnp.cjs, and git clone is install-free) fast and reproducible. But it is a dependency cache, not a build/test result cache:

  • No build/test result caching. foreach re-executes scripts every run; there is no --filter ...^... input-hash skip the way Turborepo and Nx provide. JS is interpreted, so the missing artifact cache hurts less for "build" than for, say, Bazel — but TypeScript compilation, bundling, and test runs are exactly the kind of work a result cache would skip, and Berry does not.
  • No REAPI / remote execution. There is no remote build cache or REAPI backend (contrast Turborepo's remote cache, or Bazel/Buck2 with BuildBuddy/NativeLink). Teams wanting remote task caching layer Turborepo or Nx on top of Yarn workspaces.

For a dub proposal the lesson is delimited: Berry demonstrates a robust content-addressed dependency cache + lockfile reproducibility + Zero-Installs, but contributes nothing on task-output caching or remote execution — those come from the JS/TS task orchestrators and polyglot engines elsewhere in this survey.

5. CLI / UX Ergonomics

Rich, flag-driven member selection layered on a single foreach verb. Outside a workspace command, the everyday verbs are workspace-aware by cwd: yarn add @acme/greeter@workspace:^ (adds a local member dep), yarn install, yarn run build (runs the current workspace's script), and yarn workspace @acme/cli run build (run a script in a named workspace). The monorepo broadcast surface is yarn workspaces foreach, whose flags are the model the dub proposal draws from directly:

FlagEffect
-A,--allrun on all workspaces of the project
-R,--recursiveseed from the current workspace, follow dependencies/devDependencies recursively
-W,--worktreerun only on the current worktree's workspace
--from <glob>use workspaces matching the glob as the recursion roots (paired with -R)
--since [ref]only workspaces changed since a git ref (affected-detection)
-t,--topologicalwait for dependencies to finish first; --topological-dev adds devDependencies
-p,--parallelrun concurrently (≈ half the cores by default)
-j,--jobs <n|unlimited>cap (or uncap) the concurrency
-i,--interlacedstream output live instead of buffering per process
--include / --excludeglob whitelists / blacklists over workspace idents or paths
--no-privateskip private workspaces (e.g. for npm publish)
-n,--dry-runprint what would run

So the idiomatic "build everything in dependency order, in parallel" is yarn workspaces foreach -Apt run build, and "build only what changed since main and its dependents" is yarn workspaces foreach -Rpt --since main run build. This is the most complete native member-slicing CLI of any package manager in the catalog (the task orchestrators match or exceed it, but they are dedicated runners). The colon (@scope/name) is a package-naming convention, not a target syntax; selection is entirely flag-driven over idents and paths.

Workspace constraints

A sixth, Berry-specific capability worth flagging for the dub proposal's "Workspace Constraints Engine" milestone: yarn constraints (constraints.ts) validates that workspace manifests conform to rules written in yarn.config.cjs, and yarn constraints --fix rewrites manifests to satisfy them (a multi-pass, up-to-10-iteration fixpoint). Yarn 4's engine is plain JavaScript (defineConfig from @yarnpkg/types), having replaced the earlier Prolog (constraints.pro) engine. The repo's own yarn.config.cjs enforces, for example, that every workspace depends on the same version of a shared dependency:

js
// yarn.config.cjs (abridged) — enforce one version of each dep across the monorepo
for (const dependency of Yarn.dependencies()) {
  for (const otherDependency of Yarn.dependencies({
    ident: dependency.ident,
  })) {
    dependency.update(otherDependency.range);
  }
}

This is exactly the "no version drift across members" rule that Composer needs monorepo-builder validate for, made first-class and auto-fixable.


Strengths

  • First-class local cross-references. The workspace: protocol links members by source directory (LinkType.SOFT), never fetches, never persists the resolution, and rewrites to a real registry range on publish — the cleanest local-first-then-publishable model in the survey.
  • Real topological task runner. yarn workspaces foreach -Apt runs the member graph in dependency order with bounded concurrency, cycle detection, and per-process output control — built in, no extra tool.
  • Strict dependency isolation by default. PnP's .pnp.cjs map makes phantom dependencies impossible; nodeLinker lets a team trade strictness for compatibility (node-modules) or pick an isolated symlink store (pnpm).
  • Affected-detection out of the box. --since [ref] + -R bounds work to changed workspaces and their dependents via git merge-base/diff, no external graph tool needed.
  • Centralized version pinning. The catalog: protocol and the JS yarn constraints engine eliminate version drift across members and can auto-fix manifests.
  • Zero-Installs + reproducibility. Committing the content-addressed cache and .pnp.cjs makes git clone install-free; yarn.lock checksums guarantee reproducible installs.
  • Plugin architecture. Protocols, linkers, and commands are all plugins, so the core stays small and the model is extensible.

Weaknesses

  • No task-output cache. foreach always re-runs the selected scripts; there is no input-hash "already up to date, skip" memoization — teams add Turborepo/Nx for that.
  • No remote execution / remote cache. No REAPI backend and no shared remote build cache; the cache is dependency archives only.
  • PnP compatibility friction. Tools that assume a physical node_modules/ (some bundlers, IDEs, postinstall scripts) need PnP-aware shims or the node-modules linker; PnP's strictness occasionally surfaces real-but-annoying peer-dependency errors.
  • No virtual-workspace mode. The root is always a package; there is no Cargo-style stateless virtual root (mitigated by "private": true, but conceptually less clean).
  • Selection is git/glob-based, not content-based. --since detects changed files, not changed inputs to a task, so it can over- or under-select relative to a true input-hash model.
  • Migration cost. Berry is a large departure from Yarn Classic / npm; PnP, the .yarn/ layout, and corepack pinning are a real adoption ramp.

Key design decisions and trade-offs

DecisionRationaleTrade-off
Root-package workspace via a workspaces glob arrayReuses the existing package.json; no new manifest file; recursive discoveryNo distinct virtual-root mode; root is always a (private) package
workspace: protocol with LinkType.SOFT, resolution not persistedLocal-first: edits to a member are instantly visible; never fetches or pins a siblingThe workspace graph must be re-derived each install; cross-workspace ranges are special
Publish-time selector rewriting (*/~/^/explicit)One range works in dev and becomes a correct registry constraint on publish, no manual editSelector semantics are subtle; authors must know which selector publishes to what
Plug'n'Play as the default linkerStrict isolation (no phantom deps), near-instant installs, Zero-InstallsCompatibility friction with node_modules-assuming tooling; needs PnP-aware shims
nodeLinker choice of pnp / pnpm / node-modulesLets each repo pick its point on the isolation-vs-compatibility spectrumThree install shapes to support and document; behavior differs across them
foreach topological runner with p-limit concurrencyBuilt-in dependency-ordered, parallel task execution; clean cycle detectionAlways re-runs (no result cache); orchestration only, not memoization
--since git-ref affected detection (not input hashing)Cheap, dependency-free "what changed?" using git plumbingDetects changed files, not changed task inputs; can mis-scope vs a true hash model
Content-addressed dependency cache + lockfile checksumsReproducible, offline, Zero-Install-capable dependency installsCaches downloads, not build/test outputs; no remote task cache
Constraints engine (yarn constraints --fix) in JSFirst-class, auto-fixable cross-member rules (e.g. single shared version)Another config surface (yarn.config.cjs); replaced the older Prolog engine

Sample workspace

A minimal, genuinely-runnable Yarn Berry workspace lives in ./sample/: a private root declaring workspaces: ["packages/*"], two members where @acme/cli depends on @acme/greeter via workspace:^, and a build script driven topologically with yarn workspaces foreach -Apt run build. It demonstrates all five dimensions in ~30 lines of config.


Sources