Greg Wilson’s recent post An E-Bike for the Mind reminded me of Jorma Sajaniemi’s work on the roles of variables. Sajaniemi found that just eleven roles cover nearly all variables in novice programs: stepper, most-wanted holder, gatherer, one-way flag, and so on. As Wilson puts it, types tell you about a variable’s state at rest while roles tell you about its state in motion. Once you learn the roles, you can look at unfamiliar code and immediately recognize the shape of the algorithm from how data flows through it.
Every package in a registry plays a particular role, whether it’s a library your application calls, a tool your build pipeline runs, a daemon your infrastructure depends on, or a firmware blob that makes your hardware work. This holds across all kinds of package managers, from npm and RubyGems to Homebrew, apt, Helm, Terraform Registry, and OpenVSX, and the role tells you more about how a package fits into a system than the name or the README. Two packages in completely different domains, managed by completely different tools, can behave identically because they play the same role.
Code execution
Application. neovim and ffmpeg via Homebrew, httpie via pip, VS Code extensions via OpenVSX, desktop apps via Flatpak or Snap. Standalone programs distributed through a package manager, meant to be run directly rather than imported into other code. The package manager is acting as a software distribution channel rather than a dependency manager. System package managers like apt and Homebrew have always handled this role naturally, while language package managers treat it as an afterthought. Dev tooling like eslint, prettier, rubocop, and cargo-edit are applications too, but they live in a project’s manifest as dev dependencies rather than being installed globally, and no line of application code ever imports them. Some registries distinguish between library and binary packages, though most treat them identically.
Library. The most common role by far: exports functions, classes, or modules that your code calls directly. Rails’ ActiveSupport, Python’s requests, Rust’s serde, Lodash, Guava. Your code depends on the library’s interface, you control when and how to invoke it, and the library knows nothing about your code. Some libraries bundle a broad surface area under one namespace (Lodash, Apache Commons, Boost), but that’s a property of the library rather than a different role.
Framework. Inverts the library relationship: you write code that the framework calls. Rails, Django, Next.js, Spring Boot. The framework owns the execution lifecycle and your code fills in the blanks. Frameworks tend to be deep dependencies that are expensive to replace, because your code is shaped around their conventions rather than the other way around.
Plugin. Extends another package and can’t function on its own, because it conforms to a host’s extension API. Babel plugins, ESLint rules, Jekyll plugins, Terraform providers, Rack middleware, webpack loaders, ActiveRecord database adapters. Some compose in a pipeline (Rack middleware), some transform files during a build (webpack loaders, Babel presets), and some implement a swappable backend interface (Faraday HTTP adapters, Rails cache backends), but in every case the relationship is the same: extending a host through a contract the host defines. A framework with a rich plugin ecosystem has a moat that a technically superior replacement can’t easily cross.
Wrapper. An idiomatic interface to something written in a different language or running as an external service. nokogiri wraps libxml2, pg wraps libpq, and the AWS, Stripe, and Twilio SDKs wrap HTTP APIs. Wrappers carry an implicit second dependency on the thing being wrapped, not always declared in the package metadata. Native extension wrappers are the source of most “failed to build gem” errors because the system library might not be installed.
Polyfill. Backports functionality from a newer version of a language or platform to an older one. core-js for JavaScript, future for Python 2/3 compatibility, activesupport core extensions for Ruby. Polyfills are supposed to disappear once you drop support for the older runtime, but in practice they linger for years because nobody audits minimum version requirements. The JavaScript ecosystem still carries enormous dependency trees from the ES5-to-ES6 transition, installed in projects targeting only modern browsers.
Build and development
Compiler. Transforms source code from one language or version to another. Babel, TypeScript (tsc), CoffeeScript, Sass, PostCSS. Dev dependencies that run at build time and produce output that replaces the input. The source code you write depends on the compiler, but the code your users run doesn’t. Similar to CLI tools in their dependency role, but they shape your source code more deeply because you write in the compiler’s input language.1
Types. Only type definitions, no runtime code. The @types scope on npm is the canonical example: @types/node, @types/react, thousands of others. These exist because TypeScript needs type information for packages written in JavaScript. The entire DefinitelyTyped project is a parallel registry of type-only packages that shadow real packages. Python has a similar pattern with types-requests, types-PyYAML, and stub packages for mypy. They vanish entirely from production builds.
Generator. Scaffolds new projects or components, typically run once and never imported again. create-react-app, yeoman generators, rails new, cargo-generate. The generated output is the thing you maintain, not the generator itself. Some ecosystems install these globally, some use npx-style one-shot execution, and some bundle them into the framework CLI. The relationship between a generator and the code it produces is a dependency that no lockfile captures.
Artefacts
Data. Ships data rather than code: timezone databases (tzdata, tzinfo-data), Unicode character tables, country and currency lists, language detection models, word lists. Shared tool configurations (eslint-config-airbnb, @tsconfig/recommended) are data packages too, turning coding style decisions into installable dependencies with their own update and compatibility concerns. The package manager is being used as a distribution mechanism for datasets that need to be versioned and depended on just like code. Some of these are large enough that registries end up debating size limits. The IANA timezone database gets new releases when countries change their daylight saving rules, and every language ecosystem has its own repackaged copy.
Asset. Non-code, non-data resources that a system or application needs to function. Fonts (fonts-liberation, ttf-mscorefonts), icon sets, SSL certificate bundles (ca-certificates), sound files, locale definitions. Resources that some other piece of software expects to find on disk, not code you call or structured data you query. System package managers handle these naturally, while language package managers mostly ignore them or push the problem to Docker and system-level provisioning.
Schema. Defines the shape of data exchanged between systems, distributed as a shared dependency that both sides of a boundary rely on. Protobuf definition packages, OpenAPI specs, JSON Schema packages, GraphQL schema files, Avro schemas, AsyncAPI definitions. Language-agnostic interface definitions that get compiled into types and code for each consumer, distinct from type packages (which are language-specific and exist to satisfy a type checker). Both the producer and consumer of an API or message format depend on the same schema package, turning it into a coordination point where a version bump on one side forces a response on the other.
Meta. Declares dependencies but contains little or no code of its own, pulling in a curated set of other packages when installed. The rails gem depends on activerecord, actionpack, activesupport, and the rest, but the gem itself is mostly a gemspec. Debian and other system package managers use meta-packages extensively for grouping (build-essential, texlive-full). In npm, packages like react-scripts bundle a whole toolchain behind a single dependency. Convenient, but they hide what you actually depend on, and auditing gets harder.
Environment
Runtime. The execution environment itself, treated as a versioned package. At the system level: Ruby in .ruby-version, Python in .python-version, Node.js in engines, managed by tools like rbenv, pyenv, nvm, mise, and asdf. At the package level, Electron embeds a browser runtime and esbuild ships platform-specific binaries as npm packages. The package manager or version manager becomes a distribution channel for the thing that runs your code, and a version mismatch here tends to produce errors from a layer that most tooling assumes is fixed.
Service. Installs and manages a long-running daemon rather than linking code into your application: PostgreSQL, Redis, Nginx, Elasticsearch. In system package managers like apt and yum, installing a service package starts a background process and registers it with an init system. Your application depends on the service being available at runtime, but the relationship is over a network socket or IPC rather than a function call.
Driver. Enables communication with hardware or provides low-level system capabilities. Firmware packages (firmware-linux-nonfree, linux-firmware), kernel modules, GPU drivers. Binary blobs or compiled modules that sit between the operating system and physical devices. Language package managers never touch this layer, but system package managers treat drivers as versioned dependencies like anything else, and getting the wrong version can make a machine unbootable. No other role on this list has that risk profile.
Infrastructure. Declares the desired state of a system or environment rather than code that runs inside an application. Helm charts, Puppet modules, Ansible roles and collections, Chef cookbooks, Terraform modules, Salt formulas, Nix derivations. Distributed through their own registries (Puppet Forge, Ansible Galaxy, Terraform Registry, Artifact Hub), versioned, and depended on, but containing configuration and orchestration logic rather than application code. The relationships between infrastructure packages can be just as deep and tangled as in any language ecosystem, and the consequences of a bad version are often more severe because they affect running infrastructure rather than a build that fails locally.
Middleware (Rack, Express, ASGI) is a plugin that composes in a pipeline. Loaders (webpack loaders, Babel presets) are plugins for build tools. Collections (Lodash, Apache Commons) are libraries with a broad API surface. Adapters (ActiveRecord database backends, Faraday HTTP adapters) are plugins that implement a swappable backend interface. Shared tool configuration (eslint-config-airbnb, Prettier configs) is data that a dev tool reads. In each case, the relationship to the rest of the system didn’t differ enough from an existing role to justify its own entry. Policy (OPA policies, Gatekeeper constraints, Sigstore bundles) and facade (simplified glue interfaces) were also considered, but policy feels like a specialization of data or infrastructure depending on context, and facade overlaps too much with wrapper.
Rails is both a framework and a meta-package. ESLint is an application with a plugin architecture and an ecosystem of shared config data packages. ActiveRecord’s PostgreSQL adapter is both a plugin (it conforms to ActiveRecord’s backend interface) and depends on pg, a wrapper (it provides an idiomatic Ruby interface to libpq). The taxonomy gets more useful when you can describe a package as the intersection of two roles rather than forcing it into one, because the combination tells you something neither role says alone.
You wouldn’t pin a data package tightly, because it needs to update when the world changes, not when the code changes. Upgrading a framework carries more risk than upgrading a library, because your code is shaped around the framework’s conventions and a breaking change ripples through everything, while a swappable backend plugin is designed so you can replace one implementation without touching the rest of your application. Types, CLI tools, compilers, and generators almost never belong in production dependencies, though they routinely end up there because nobody set the dependency scope correctly.
Wrappers with native extensions carry a larger attack surface than pure-code libraries, a distinction that matters more as supply chain security tooling matures and starts treating different kinds of dependencies differently. Experienced developers already make these judgments instinctively.
-
There’s an emerging grey area between compiler and library: packages like
styled-componentsandtRPCprovide a runtime API that gets partially or fully erased at build time by a compiler plugin. They behave as libraries in your source code but as compiler inputs in your build pipeline, straddling two roles at once. ↩