Dependency version ranges and protocols

This lesson preview is part of the Bundling and Automation in Monorepos course and can be unlocked immediately with a \newline Pro subscription or a single-time purchase. Already have access to this course? Log in here.

This video is available to students only
Unlock This Course

Get unlimited access to Bundling and Automation in Monorepos, plus 90+ \newline books, guides and courses with the \newline Pro subscription.

Thumbnail for the \newline course Bundling and Automation in Monorepos
  • [00:00 - 00:10] I want to speak about how we define version ranges in our dependencies, and what extra can we do in that version specifier. More concretely, dependency protocols.

    [00:11 - 00:19] To do that, I'm going to create a "version-ranges-example" folder. I'm going to sit into it and then I'm going to create an empty package.json.

    [00:20 - 00:31] Then I'm going to run "pnpm install", so we have our "packageManager" setup, and we want to add one dependency. So I'm going to use TypeScript as our example dependency.

    [00:32 - 00:47] When we open package.json we can see that TypeScript was added with version specifier of a caret prefix and then a version number of 5.7.2 at the time of recording. This is the latest version of TypeScript.

    [00:48 - 01:28] So the behavior of pnpm and of npm and yarn as well is that when you add a dependency without any specifier for it's version, it's going to use the caret version prefix specifier, followed by the current latest version. We can easily verify that this is actually the latest version of TypeScript if we run "pnpm info typescript", which is going to pull information from the npm registry about the typescript package, and we can see that indeed the latest version is 5.7.2. But where does the caret symbol come from and what does it mean?

    [01:29 - 01:42] I've prepared a table where we can look at a list of possible version specifiers. I've separated out the caret one because, as I said, it's the default used by package managers.

    [01:43 - 02:03] It means that the minimum version available for a package is going to be whatever version was specified, and then any other major is not allowed. So we can upgrade within this major everything that's above our current minor version, but nothing above it.

    [02:04 - 02:17] Other possible specifiers are a specifier that doesn't have any prefix is exactly that version, and it cannot be upgraded. A specifier that uses a "~" is only for patch changes.

    [02:18 - 02:36] And finally, the one that I want to call out is any zero point something version, which is a pre-release version, has a different behavior. For those, a caret means stay within the same minor version, and a llow patch changes only.

    [02:37 - 02:47] So that's the odd one out. I have listed other possible changes that you can use to fine tune a version range for a dependency that you're comfortable with.

    [02:48 - 03:17] In the end, what the version range specifies is when you run "pnpm upgrade", what is the maximum version that a package that you currently have as a dependency can be upgraded to. There is another version specifier that I want to highlight which is the "latest" specifier. It just means that you can always upgrade to the latest version of a package, and it behaves the same as the "*" specifier.

    [03:18 - 04:03] The other that I want to point out is that each package can define something called dist-tags, and you can use those named tags to install a specific version. For example, I know that TypeScript has the "next" tag, so it would be valid for me to have a "typescript" dependency with a version specifier of "next". And indeed, if we look back to the information pulled from the registry, we can see that under dist-tags, typescript specifies a "beta", a "latest", "dev" and a "next" tag along some others, and you can use all of that as valid version specifiers for your dependencies.

    [04:04 - 04:34] Now, when I said that the caret version prefix is the default prefix, this also means that we can overwrite it. We can do that with "pnpm config set save-prefix=~", I'm going to use here, and I just want to also add "--location=project" so that this "config set" does not overwrite my global pnpm configuration, but instead gets written in a local .npmrc file.

    [04:35 - 05:02] We can open this .npmrc file, and we can see that our config override was added. And we could have also just done this directly by editing this file. As an example, I'm going to add "eslint" without specifying any version and after it's installed, let's open package.json and we can see that it has the "~" version prefix as opposed to the default "^" prefix.

    [05:03 - 05:49] In general, I suggest keeping the caret prefix, because most of the time when you want to upgrade dependencies, you are fine with upgrading to a new minor version, but you might not be fine with upgrading to a new major version, as that can introduce breaking changes. So it's good to make upgrades to minor versions easy, while upgrades to major versions should be a bit more difficult and probably should require you to manually specify a new major version for a package when you want to upgrade. So now that we understand how version specifiers work, I want to talk about dependency protocols and where they're needed and how they're used.

    [05:50 - 06:13] I'm going to move to our example monorepo, and I'm going to again use code from later in the course because it's going to make for a good example. I'm going to show you the shape of the code right now, and the only thing that we care is that we do have our three apps, and we have a "packages" folder that contains shared code.

    [06:14 - 06:35] I have my "pnpm-workspace.yaml" config set to look for workspaces under the "apps" folder and under the "packages" folder. I'm going to be using the shared config package that's under "packages/config" and its name is "@monorepo/config".

    [06:36 - 06:46] The name here is chosen so that it doesn't exist in a public npm registry. It's a local name for a package that will be able to install into our own apps.

    [06:47 - 07:08] I'm going to create a new app for our example and I'm going to put it in "apps/workspace-dependencies-example". I'm going to quickly create a package.json, run "pnpm install" and then try to add a dev dependency on our "@monorepo/config" package.

    [07:09 - 07:38] This is going to fail because npm does not contain a package called "@monorepo/config", and this is expected. This is supposed to be an internal package only for our own consumption, and not something we want to be publishing on npm. The way to tell pnpm to install a package from local workspaces is to add the "--workspace" option to our add command.

    [07:39 - 07:48] It has been correctly added to our dev dependencies, but the version seems a bit strange. It's "workspace:^". What does that mean?

    [07:49 - 07:57] Well, the caret part is easy - this is just the default version prefix. The "workspace:" is a bit more complicated.

    [07:58 - 08:08] This is what's called a protocol for a dependency. Let me open a file called "protocols.md", and I'm going to paste in a table that I created ahead of time.

    [08:09 - 08:22] npm is the default protocol. If you just have a version specification it's going to be using npm. But you can also manually always say "npm:" and then your version specification.

    [08:23 - 08:34] That means use the npm registry for this particular dependency. You can also set up other registries and use those, but we're not going to go into that.

    [08:35 - 08:52] The other protocol that we care about is the "workspace" protocol. Workspace just defines that this dependency must come from your local monorepo rather than any external registry or external package location.

    [08:53 - 09:01] And I'm just going to mention the other protocols. You can use a "file" protocol to do a file system require.

    [09:02 - 09:07] That might be outside your workspace. It can be a folder that's just not part of your workspace.

    [09:08 - 09:16] You can use "http", "https", "git", or "github". All of these are valid ways to specify a dependency to be installed.

    [09:17 - 09:49] In practice, the "github" one seems to be the one most likely you would need to use in a real project. In terms of adding workspace dependencies to our internal apps, I just want to point out that these two commands are the same. You can do "pnpm add @monorepo/config --workspace" and you can do "pnpm add @monorepo/config@workspace:^".

    [09:50 - 10:00] The resulting definition in your dependencies is going to always be the same. Throughout this course, I'm going to be using the second variant because it's more explicit in what it does.

    [10:01 - 10:25] It's saying that they want to add this package using this exact version specification, rather than having pnpm write out the "workspace:^" version specification, for me. The last thing I want to discuss is using aliases for our dependencies. Let me open a aliases.md file and I'm going to show you a couple of options.

    [10:26 - 11:00] Because of the npm protocol, we can actually rename one dependency to another. We can say I want to have a dependency called "lodash", but I want it to actually be resolved from the npm protocol and a dependency with name "awesome-lodash". "awesome-lodash" doesn't actually exist, but the idea is that you would be able to alias one dependency to another using this. Now, I don't recommend doing this in a production project, but it's an option that you should be aware of.

    [11:01 - 11:18] The other interesting thing that we can do with aliases is that we can have two versions of the same dependency installed at the same time. And the example that I have here is ESLint version eight and ESLint version nine.

    [11:19 - 11:30] Let's quickly run through this. I'm going to do "pnpm add eslint8@npm:eslint@^8".

    [11:31 - 11:46] This means for this dependency, use the npm registry and pull the "eslint" package at version eight. And then I'm going to also add "eslint" directly without any specifier.

    [11:47 - 12:04] As you can see, it gets added as a normal dependency at version nine. If we run "pnpm list", we're going to see that now this project has ESLint at two versions as a dependency at both version eight and version nine.

    [12:05 - 12:11] You might be wondering, why would you need to do this? And this is something that we'll be exploring a bit later in the course.

    [12:12 - 12:40] Just as a sneak peek, later in the course, we're going to be writing a shared config for ESLint eight and ESLint nine. And to do that we need to be able to import from "@eslint/js" at both version eight and nine. So this is where being able to have the same package at different versions can come in handy. And that's all for now.

    [12:41 - 12:41] Catch you in the next one.