Builders
The PurlBuilder fluent API — construct a PackageURL step by
step, with per-field setters and per-ecosystem factories. Read
this when you want to build a PURL from computed values rather
than handing every argument to a constructor all at once.
Who this is for #
Callers with a runtime shape that doesn't fit a single constructor call — you have a loop, a conditional, or a pipeline where each piece of the PURL lands at a different step. Also contributors adding a new ecosystem factory.
When to use what #
| You have… | Use |
|---|---|
| All six pieces in hand at once | new PackageURL(type, ns, name, version, qualifiers, subpath) — positional, fastest. |
| A loop / conditional that sets fields over time | PurlBuilder — method chaining, field-by-field. |
| An existing PackageURL and want to tweak one field | PurlBuilder.from(existing).name('new').build() — creates a fresh instance since PackageURL is frozen. |
| A string from the wire | new PackageURL(str) or PackageURL.fromStringResult(str) — see docs/hardening.md. |
The builder is not faster than the constructor; it is easier to read when construction is spread across code.
The fluent API at a glance #
import { PurlBuilder } from '@socketregistry/packageurl-js'
const purl = PurlBuilder.create()
.type('npm')
.namespace('@scope')
.name('left-pad')
.version('1.3.0')
.qualifier('extension', 'tgz')
.subpath('lib')
.build()
purl.toString()
// 'pkg:npm/%40scope/left-pad@1.3.0?extension=tgz#lib'
Every setter returns this so calls chain. build() returns the
frozen PackageURL and validates — if a required field is missing
or a value fails its component's validator, build() throws.
The setters #
| Method | Sets | Notes |
|---|---|---|
.type(str) |
The package type (npm, pypi, maven, …). Required. |
Lowercased. Must match a registered PurlType. |
.namespace(str) |
Namespace / scope / group (e.g. @scope for npm, org.acme for maven). Optional. |
Normalization depends on type. npm lowercases; maven preserves case. |
.name(str) |
Package name. Required. | Same as namespace — normalization per type. |
.version(str) |
Version string. Optional. | Free-form; validated for injection chars but not semver-shape (ecosystems disagree). |
.qualifier(key, value) |
One key-value qualifier. Add many by chaining multiple calls. | See the known-qualifier list below. |
.qualifiers(obj) |
Set all qualifiers at once from an object. | Replaces any previously-set qualifiers. |
.subpath(str) |
Subpath within the package (e.g. lib/utils). Optional. |
Leading/trailing slashes are stripped. |
.build() |
Finalize. | Throws on invalid. |
The per-ecosystem factories #
For common ecosystems, the builder has a static shortcut that pre-
sets .type():
// These two are equivalent:
PurlBuilder.create().type('npm').name('lodash').version('4.17.21').build()
PurlBuilder.npm().name('lodash').version('4.17.21').build()
Available factories:
| Factory | Ecosystem | Preset type |
|---|---|---|
PurlBuilder.bitbucket() |
Bitbucket repos | bitbucket |
PurlBuilder.cargo() |
Rust crates | cargo |
PurlBuilder.cocoapods() |
iOS/macOS pods | cocoapods |
PurlBuilder.composer() |
PHP packages | composer |
PurlBuilder.conan() |
C/C++ (Conan Center) | conan |
PurlBuilder.conda() |
Conda packages | conda |
PurlBuilder.cran() |
R packages | cran |
PurlBuilder.deb() |
Debian packages | deb |
PurlBuilder.docker() |
Docker images | docker |
PurlBuilder.gem() |
Ruby gems | gem |
PurlBuilder.github() |
GitHub repos | github |
PurlBuilder.gitlab() |
GitLab repos | gitlab |
PurlBuilder.golang() |
Go modules | golang |
PurlBuilder.hackage() |
Haskell packages | hackage |
PurlBuilder.hex() |
Elixir/Erlang packages | hex |
PurlBuilder.huggingface() |
Hugging Face models | huggingface |
PurlBuilder.luarocks() |
Lua packages | luarocks |
PurlBuilder.maven() |
Maven Central | maven |
PurlBuilder.npm() |
npm packages | npm |
PurlBuilder.nuget() |
.NET packages | nuget |
PurlBuilder.oci() |
OCI containers | oci |
PurlBuilder.pub() |
Dart/Flutter | pub |
PurlBuilder.pypi() |
Python packages | pypi |
PurlBuilder.rpm() |
RPM packages | rpm |
PurlBuilder.swift() |
Swift packages | swift |
Generic entry points:
PurlBuilder.create()— no type preset. You must call.type()before.build().PurlBuilder.from(existing: PackageURL)— seeds every field from an existingPackageURL. Useful for "take this PURL but change one field."
Known qualifier keys #
Qualifiers are an open key-value space, but the PURL spec (and downstream tooling) standardizes a few:
| Qualifier | Meaning |
|---|---|
checksum |
Digest of the artifact (e.g. sha256:abc…). |
download_url |
Direct URL to download the artifact. |
file_name |
Filename of the distributed artifact (e.g. tar.gz, whl). |
repository_url |
URL of the source repository. |
vcs_url |
VCS (git/hg) URL, including commit reference. |
vers |
A VERS range (see docs/vers.md) constraining the version. |
The library knows these keys and normalizes their values; custom
keys pass through untouched. See
src/purl-qualifier-names.ts for the canonical list.
Worked examples #
Build from a package.json entry #
function purlFromPackageJson(name: string, version: string): PackageURL {
const builder = PurlBuilder.npm().version(version)
// npm scoped packages: '@scope/pkg' → namespace '@scope', name 'pkg'
if (name.startsWith('@')) {
const [scope, pkg] = name.split('/')
builder.namespace(scope).name(pkg)
} else {
builder.name(name)
}
return builder.build()
}
purlFromPackageJson('lodash', '4.17.21').toString()
// 'pkg:npm/lodash@4.17.21'
purlFromPackageJson('@scope/pkg', '1.0.0').toString()
// 'pkg:npm/%40scope/pkg@1.0.0'
Build with a download URL qualifier #
const purl = PurlBuilder.pypi()
.name('requests')
.version('2.31.0')
.qualifier('extension', 'tar.gz')
.qualifier(
'download_url',
'https://files.pythonhosted.org/…/requests-2.31.0.tar.gz',
)
.build()
purl.toString()
// 'pkg:pypi/requests@2.31.0?download_url=…&extension=tar.gz'
Note that qualifiers are alphabetized in the canonical output.
Tweak one field on an existing PURL #
const original = new PackageURL('npm', undefined, 'lodash', '4.17.20')
const updated = PurlBuilder.from(original).version('4.17.21').build()
original.toString() // 'pkg:npm/lodash@4.17.20' (unchanged; frozen)
updated.toString() // 'pkg:npm/lodash@4.17.21'
PurlBuilder.from() is the only sanctioned way to produce a
modified copy. Direct mutation is impossible by design (see
docs/hardening.md).
Chain many qualifiers #
PurlBuilder.maven()
.namespace('org.apache.logging.log4j')
.name('log4j-core')
.version('2.17.1')
.qualifier('classifier', 'sources')
.qualifier('extension', 'jar')
.qualifier('type', 'sources')
.qualifier('repository_url', 'https://repo.maven.apache.org/maven2')
.build()
Alternative using .qualifiers(obj):
PurlBuilder.maven()
.namespace('org.apache.logging.log4j')
.name('log4j-core')
.version('2.17.1')
.qualifiers({
classifier: 'sources',
extension: 'jar',
type: 'sources',
repository_url: 'https://repo.maven.apache.org/maven2',
})
.build()
Both produce the same PURL. Use .qualifier() when adding one at
a time inside a loop; use .qualifiers() when you have the whole
object already.
Validation timing #
The builder does not validate as you set — so:
PurlBuilder.create().type('npm').name('') // empty name — won't error here
Validation runs when you call .build(). That call constructs a
new PackageURL, which invokes the per-component validators. A
failure at .build() throws with a message pointing at the
offending field.
This "fail late" design lets you construct a builder in one place and pass it around (e.g. to helper functions that set more fields) without each mutation being a potential throw site. If you want "fail early," prefer the constructor and check the throw at a single site.
The ESM/CJS instanceof footgun #
PurlBuilder internally imports PackageURL via CommonJS
require(). If your code imports PackageURL via ESM import,
Node wraps the two imports into different objects, and
builtPurl instanceof PackageURL returns false even though the
structure is correct.
Workaround:
// Bad:
const ok = purl instanceof PackageURL
// Good:
const ok = purl && purl.constructor.name === 'PackageURL'
// Also good — use a duck-type check on the fields you care about:
const ok = typeof purl === 'object' && typeof purl.toString === 'function'
This limitation is a Node ESM/CJS interop artifact, not a library
bug. Affects only instanceof, not any actual functionality.
Adding a new ecosystem factory #
If you implement a new PurlType handler under
src/purl-types/<name>.ts, add a matching PurlBuilder.<name>()
factory:
static <name>(): PurlBuilder {
return new PurlBuilder().type('<name>')
}
Conventions:
- Method name: ecosystem name, lowercase.
- Body:
new PurlBuilder().type('<name>'). No other presets. - Doc comment: one-line description matching the per-ecosystem-factory table above.
- Alphabetical order in the class.