Skip to main content

Why We Added package.json Support to Deno


The latest release of Deno introduced a significant change: providing enhanced Node and NPM compatibility through package.json support. This update led to questions regarding whether our priorities have shifted, as Deno has long been associated with forging a path distinct from Node. Indeed, package.json was explicitly mentioned as a regret in the first Deno presentation. Thus, many users were surprised by this development.

In this post, we’ll address these concerns, share insights into our evolving thoughts, and outline our future goals.

Simplifying and accelerating JavaScript development

Deno was created to simplify and accelerate JavaScript development. Core features include native TypeScript support, built-in tooling, zero configuration by default, and web standard APIs. Historically, this has also meant a minimalist, decentralized module system separate from the NPM ecosystem, based on web standard HTTP imports.

Deno’s main goal is to make programming both easy and fast. TypeScript, web standard APIs, and many other features contribute to this aim, but it has become increasingly unclear whether Deno’s minimalist module system is making programming easy and fast.

The dependency management dream

The JavaScript ecosystem’s reliance on a single centralized module registry conflicts with the web’s decentralized nature. With the introduction of ES modules, there is now a standard for loading remote modules, and that standard looks nothing like how module loading through NPM works. Deno implements loading ES modules through HTTP URLs - allowing anyone to host code on any domain simply by running a file server.

This approach offers great benefits. Single file programs can access robust tooling without needing a dependency manifest (a file that lists dependencies and where to get them, for example package.json, import_map.json, Cargo.toml, or Gemfile). Also Deno programs benefit from smaller downloads, as only the required files are downloaded instead of large npm package tarballs containing unnecessary artifacts. Additionally, using HTTP for module linking opens up opportunities for innovation, such as displaying HTML documentation based on the accept header, as demonstrated in the deno.land registry.

We have been committed in our pursuit of using HTTP imports as the backbone of the Deno module system. We’ve built all sorts of useful features into the deno.land registry like publish-on-tag GitHub integration, immutable caching, and auto-generated documentation. Third-party registries like skypack and esm.sh have made individual files in NPM packages accessible as ES modules using HTTP imports. We’ve established conventions like deps.ts for consolidating dependencies in one convenient location.

Lingering Issues

Depending on the context, module URLs like https://deno.land/std@0.179.0/uuid/mod.ts can sometimes be too specific. Not only do they identify the package (std/uuid/mod.ts), but they also specify the version (0.179.0) and the server from which to fetch it (deno.land). Issues arise when a program contains similar yet slightly different modules - if another module imports a URL referencing a slightly different version, like https://deno.land/std@0.179.1/uuid/mod.ts, both module versions will be included in the module graph despite being almost the same code. This is known as the Duplicate Dependency Problem (follow the link for a more concrete example of this problem).

In library code we’ve developed patterns like deps.ts for managing remote dependencies. However, deps.ts is not particularly ergonomic - it necessitates flattening and re-exporting every symbol one depends on. (This could be improved in the future through the Module Declarations and Import Reflection TC39 proposals.) deps.ts is generally more verbose and syntactically cluttered than package.json with bare specifiers.

Ideally Import Maps could solve this issue by allowing users to employ bare specifiers in their code and specify the URL only in the import map. However, import maps are not composable: a published library cannot use bare specifiers and an import map simultaneously, as the top-level project importing that library cannot compose the library’s import map with its own.

Transpile servers like esm.sh and skypack work well for importing some NPM modules into Deno, but they have inherent limitations. For example, if an NPM module loads a data file at runtime, these transpile servers will be unable to serve a compatible version. Issues like this result in a subpar developer experience.

The power of bare specifiers

Import statements using bare specifiers, such as the one below, are concise and familiar:

import express from "express";

Bare specifiers ("express") provide a usefully ambiguous reference to the dependency, which allows the Duplicate Dependency Problem to be resolved through semver resolution. However, if libraries are written using bare specifiers, there must be a dependency manifest to clarify what these bare specifiers refer to.

Simplifying and accelerating with compatibility

We want to enable Deno users to work more efficiently than they would with Node. Developers want to import a library and make use of it without hassle. Hence our npm specifier support. Beyond libraries, developers want to run existing Node projects directly in Deno. Hence the newly added package.json support.

Deno’s backwards compatibility keeps less ideal legacy features of Node and NPM at a distance. For example, Deno only supports CommonJS through NPM imports. Also Deno does not let the user refer to built-in Node modules using bare specifiers ("fs") outside of NPM dependencies, rather they must use "node:fs". Or for instance, in Deno setTimeout will return a number per the Web standard (unlike Node). Deno will not run post-install scripts arbitrarily and enforces user space security permissions. Backwards compatibility doesn’t mean the JavaScript ecosystem cannot evolve and improve.

It’s crucial to note that Deno will always support linking to code through URLs, continuing to operate like browsers with HTTP imports. Using https: URLs is now just one method of linking code in Deno. Since Deno 1.28 we have npm: URLs, and it’s easy to envision others that could improve developer velocity, such as github: URLs.

A new major version

We are working towards a new major release of Deno in the coming months. The major theme for this upcoming release is encouraging the use of bare specifiers in Deno workflows.

Although Deno has fantastic backwards compatibility with NPM modules, we will not be recommending that Deno code distributed for Deno users be published there. If one needs to share code with Node and NPM projects we recommend our official Deno to NPM compiler, DNT, that outputs high quality NPM packages containing transpiled Node compatible JavaScript and TypeScript declaration files derived from the original TypeScript. However to live in a truly TypeScript-first world, it’s best to distribute and link to the actual TypeScript code, not some compiled output.

In order to solve the duplicate dependency problem and improve the ergonomics of using the TypeScript-first Deno module registry, the next major version of Deno will introduce a deno: URL scheme. By using these special URLs, rather than HTTP URLs, the Deno runtime is able to do semver resolution and module deduplication. Furthermore it eliminates the need to write out a full URL.

For example, importing Oak will look like this:

import oak from "deno:oak@12";

Note that only the major version is specified - the runtime will have special semver resolution logic to find the right version of Oak that matches.

The next major version will also advocate the use of import maps, as a modern alternative to package.json workflows. Import maps in Deno are specified in the auto-discovered deno.json configuration file. Here’s an example of what it will look like:

{
  "imports": {
    "oak": "deno:oak@12",
    "chalk": "npm:chalk@5"
  }
}

This configuration enables any code to utilize the "oak" and "chalk" bare specifiers throughout the code. Oak comes from the Deno registry, Chalk comes from the NPM registry. Imports in the code would simply be:

import oak from "oak";
import chalk from "chalk";

Whether using modern import map workflows or Node.js package.json workflows, Deno aims to be a reliable tool developers reach for to accelerate their work. JavaScript, the world’s default programming language, deserves this continued effort to improve its ecosystem and tooling.



Keep up with the latest developments by following @deno_land on Twitter.