npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@effected/package-json

v0.14.1

Published

package.json parsing, editing, validation and file IO as Effect schemas.

Downloads

10,773

Readme

@effected/package-json

npm License: MIT Node.js %3E%3D24.11.0 TypeScript 7.0

package.json parsing, editing, validation and file IO as Effect schemas. Package is a Schema.Class with the manifest's known fields typed — name is a branded npm name, version is a real SemVer, packageManager decodes into { name, version, integrity } — and a rest catch-all that carries every unknown top-level key through a read, edit and write cycle without losing it. Editing is immutable and dual-signature, validation is a rule set you can replace, and catalog: / workspace: specifiers expand through the @effected/npm resolver contracts as an explicit step you opt into.

Pre-release. This package is part of the @effected/* kit, in pre-1.0.0 development against a single pinned Effect v4 prerelease. Packages graduate to 1.0.0 once Effect 4.0.0 ships. To hold your own effect versions at exactly the ones the kit is built and tested against, install @effected/pnpm-plugin-effect.

Stability: unstable. This package's API surface is not yet considered complete and may change across 0.x releases. Pin an exact version — even a package marked stable before 1.0.0 can introduce a breaking change by accident, and an exact pin turns that into a type-check error rather than a runtime surprise. Full policy: release strategy.

Why @effected/package-json

Tools that rewrite a package.json usually treat it as a Record<string, unknown>: read it, mutate a key, JSON.stringify it back. That works right up until it does not. Unknown keys survive by accident rather than by design, version is a string you compare with <, and the day someone's manifest has a field your types never modeled is the day you find out whether your write path preserved it. The alternative — a strict schema over the known fields — usually solves the typing by deleting everyone's data.

This package refuses both. Known fields are typed and validated; everything else lands in rest and is flattened back to top-level keys on encode, so the on-disk shape never grows a literal rest key and never loses your customTool block. Serialization applies the canonical sort-package-json key order, alphabetizes dependency maps and strips empty ones, deterministically and locale-independently. PackageJsonFile.write does not silently resolve your workspace: specifiers on the way out, because a write that quietly rewrites your dependency values is not a write, it is a policy — so Package.resolve is a step you compose in deliberately. And every distinct failure has its own tag: a missing file, an unreadable file, invalid JSON and a document that does not satisfy the schema are four different problems with four different recoveries.

Install

npm install @effected/package-json effect @effect/platform-node
pnpm add @effected/package-json effect @effect/platform-node

Requires Node.js >=24.11.0.

All @effected/* packages are ESM-only: the exports maps publish only import conditions, so require() — including tools that resolve in CJS mode — fails with Node's ERR_PACKAGE_PATH_NOT_EXPORTED rather than loading a CJS build that does not exist. Import from an ES module.

effect v4 is the only peer dependency. @effected/semver and @effected/npm come along as ordinary dependencies — they back the version field and the resolver contracts — and spdx-expression-parse is the one external package in the tree, used to validate SPDX license expressions.

Reading and writing files needs a FileSystem and a Path implementation, provided once at the edge, from @effect/platform-node on Node. Everything except PackageJsonFile is pure and needs no platform layer at all.

Quick start

Decode a manifest, edit it, read the computed properties back:

import { Package } from "@effected/package-json";
import { Effect } from "effect";

const program = Effect.gen(function* () {
  const pkg = yield* Package.decode({ name: "@acme/widget", version: "1.0.0", private: true });
  const next = yield* Package.setVersion(pkg, "1.1.0");
  return [next.name, next.version.toString(), next.isScoped, next.isPrivate] as const;
});

console.log(Effect.runSync(program));
// => ["@acme/widget", "1.1.0", true, true]

next.version is a SemVer, not a string, so you compare it with SemVer.gt and bump it with version.bump.minor() rather than reaching for a regex.

The Package model

Editing returns a new Package. The mutation statics are dual, so Package.addDependency(pkg, "effect", "^4.0.0") and pkg.pipe(Package.addDependency("effect", "^4.0.0")) are the same call. The ones that can fail — setVersion, setName, setLicense — return an Effect with the corresponding typed error, and the rest are plain functions.

Unknown keys round-trip. toJsonString encodes through the wire codec, flattens rest back to the top level, and applies the canonical key order:

import { Package } from "@effected/package-json";
import { Effect } from "effect";

const program = Effect.gen(function* () {
  const pkg = yield* Package.decode({
    name: "widget",
    version: "1.0.0",
    scripts: { build: "tsc" },
    customTool: { flag: true },
  });
  return Package.addDevDependency(pkg, "typescript", "^6.0.0").toJsonString();
});

console.log(Effect.runSync(program));
// {
//   "name": "widget",
//   "version": "1.0.0",
//   "scripts": {
//     "build": "tsc"
//   },
//   "devDependencies": {
//     "typescript": "^6.0.0"
//   },
//   "customTool": {
//     "flag": true
//   }
// }

toJsonString takes PackageFormatOptionsindent, sort, stripEmpty, newline — if you want the raw shape instead of the canonical one.

Alongside Package the leaf concepts are usable on their own. PackageName classifies and validates npm names (isValid, isScoped, scope, unscoped) and brands them as ScopedPackageName or UnscopedPackageName. DependencySpecifier classifies any specifier string into one protocol — range, tag, git, url, npm, file, link, portal, catalog, workspace or unknown — and parses the range case into a Range from @effected/semver. Dependency pairs a name with a specifier and the kind of map it came from, exposing the same protocol predicates as getters.

Reading and writing

PackageJsonFile is the only IO in the package: one service, two methods, over core FileSystem and Path.

import { Package, PackageJsonFile } from "@effected/package-json";
import { NodeFileSystem, NodePath } from "@effect/platform-node";
import { Effect, Layer } from "effect";

const bumpMinor = Effect.gen(function* () {
  const files = yield* PackageJsonFile;
  const pkg = yield* files.read("./package.json");
  const next = pkg.copyWith({ version: pkg.version.bump.minor() });
  yield* files.write("./package.json", next);
});

const PlatformLive = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer);

Effect.runPromise(bumpMinor.pipe(Effect.provide(PackageJsonFile.layer), Effect.provide(PlatformLive)));

read fails four different ways and says which: PackageJsonNotFoundError when the file is not there, PackageJsonReadError for any other filesystem failure, PackageJsonParseError when the bytes are not JSON, and PackageDecodeError when the JSON is not a package.json. There is no exists pre-check, so a file deleted between the check and the read cannot be misreported as an IO error.

Validation

PackageValidator runs a rule set over a decoded Package and aggregates every failure into one PackageValidationError, rather than stopping at the first.

import { Package, PackageValidator } from "@effected/package-json";
import { Effect } from "effect";

const program = Effect.gen(function* () {
  const pkg = yield* Package.decode({ name: "widget", version: "1.0.0" });
  const validator = yield* PackageValidator;
  return yield* validator.validate(pkg);
}).pipe(
  Effect.provide(PackageValidator.layer),
  Effect.catchTag("PackageValidationError", (error) => Effect.succeed(error.failures.map((failure) => failure.rule))),
);

console.log(Effect.runSync(program));
// => ["has-license", "has-description", "has-repository"]

PackageValidator.layer carries the default rules (has-license, has-description, has-repository, not-private). PackageValidator.layerRules({ rules }) takes your own set instead — a ValidationRule is a name plus a check that fails with a RuleFailure. Two extra rules ship for publish gates: noUnresolvedDepsRule fails on any workspace: or catalog: specifier still in the manifest, and noLocalDepsRule fails on file:, link: and portal:.

Resolving catalog: and workspace: specifiers

Package.resolve expands catalog: and workspace: specifiers across all four dependency maps, using the CatalogResolver and WorkspaceResolver contracts from @effected/npm. This package deliberately cannot implement them — it has no view of the workspace — so the implementation arrives from context. @effected/workspaces provides the real ones; a fixed record does fine for a test.

Specifiers the resolvers answer None for are left exactly as they were.

import { CatalogResolver, WorkspaceResolver } from "@effected/npm";
import { Package } from "@effected/package-json";
import { Effect, HashMap, Layer, Option } from "effect";

const Resolvers = Layer.mergeAll(
  Layer.succeed(CatalogResolver, { rangeOf: () => Effect.succeed(Option.some("^4.0.0")) }),
  Layer.succeed(WorkspaceResolver, { versionOf: () => Effect.succeed(Option.some("1.4.0")) }),
);

const program = Effect.gen(function* () {
  const pkg = yield* Package.decode({
    name: "widget",
    version: "1.0.0",
    dependencies: { effect: "catalog:", "@acme/core": "workspace:^" },
  });
  const resolved = yield* Package.resolve(pkg);
  return [
    Option.getOrElse(HashMap.get(resolved.dependencies, "effect"), () => "unresolved"),
    Option.getOrElse(HashMap.get(resolved.dependencies, "@acme/core"), () => "unresolved"),
  ] as const;
}).pipe(Effect.provide(Resolvers));

console.log(Effect.runSync(program));
// => ["^4.0.0", "^1.4.0"]

The workspace: range modifier is honored: workspace:* takes the bare version, workspace:^ and workspace:~ prefix it, and an explicit modifier is used as-is. The projection is @effected/npm's DependencySpecifier statics with full pnpm publish semantics: the alias form workspace:<name>@<range> resolves the target package's version and becomes the npm:<name>@<range> alias pnpm publishes, and a blank catalog name selects the default catalog. A failed catalog assembly surfaces typed as @effected/npm's CatalogAssemblyError, alongside the contracts' DependencyResolutionError.

Lenient discovery

Package.decode and PackageManifest are strict: a malformed field fails the whole document. That is the right behavior for a manifest you are about to write or publish, and the wrong one for a manifest you are only sniffing — a fetched tarball, a node_modules walk, a registry response — where the document is someone else's data and one bad field should not sink the read. LenientManifest is that discovery tier: every Package field decodes to its plain permissive JSON shape (name and version accept any string, not the branded npm grammar; license accepts any string, no SPDX check; the dependency maps are plain records, not HashMaps). A field present but not even that shape degrades to absence instead of failing the document, its raw value is preserved verbatim in rest (a malformed known field is treated exactly like an unknown one), and the degradation is reported on issues:

import { LenientManifest } from "@effected/package-json";
import { Effect } from "effect";

const program = Effect.gen(function* () {
  const sniffed = yield* LenientManifest.decode({ name: "JSONStream", version: "1.0", license: 42 });
  return [sniffed.name, sniffed.version, sniffed.issues, sniffed.rest?.license] as const;
});

console.log(Effect.runSync(program));
// [
//   "JSONStream",
//   "1.0",
//   [{ field: "license", expected: "a string", value: 42 }],
//   42,
// ]

Leniency is per-field, never per-syntax: decodeResult/decode still fail typed with PackageDecodeError when the input is not a JSON object at all (null, an array, a scalar), and parseResult/parse — the pair that also handles the raw JSON.parse — fail typed with PackageJsonSyntaxError when the text is not valid JSON or does not parse to an object. Each pair follows the package's usual shape: decodeResult/parseResult are the synchronous Result primitives, decode/parse are their Effect forms with a tracing span.

An empty issues array is not a validity guarantee — the permissive shapes check JSON shape, not npm semantics, so a LenientManifest with no issues can still fail Package.decode. LenientManifest carries no mutation statics and no write path; once you need to validate or edit, re-decode the original input through PackageManifest.decode (presence-lenient, shape-on-presence strict) or Package.decode (strict, publishable).

Resolving an entry point

resolveEntryPoint answers one question about a manifest — which file is the package's "." entry — and it is pure, IO-free and Result-returning, so it works against a plain object with no package on disk:

import { resolveEntryPoint } from "@effected/package-json";

resolveEntryPoint({ exports: { import: "./esm.js", require: "./cjs.js" } });
// Result.succeed("./esm.js")

resolveEntryPoint({ exports: { require: "./cjs.js" } }, { conditions: ["require"] });
// Result.succeed("./cjs.js")

resolveEntryPoint({ main: "./legacy.js" });
// Result.succeed("./legacy.js") — no exports field, so main applies

resolveEntryPoint({ exports: { require: "./cjs.js" }, main: "./legacy.js" });
// Result.fail(UnresolvedEntryPointError { reason: "noConditionMatched" })

All three legal exports spellings are honored — the string shorthand, a subpath map, and conditions at the root with no "." key — and conditions default to ["import", "default"], in priority order.

The last case above is the semantic worth knowing: exports encapsulates the package, so a present-but-unmatched exports is a typed failure and main is not consulted. That is Node's rule. The lenient reading — falling through to main, then to index.js — answers a file the package deliberately does not export, and it loads and behaves plausibly instead of failing. Only an absent exports reaches main, and then the legacy index.js default. An exports form the resolver does not implement (an array fallback list, or a subpath map with no "." entry) fails for its own reason rather than being guessed at.

Pair it with @effected/npm's PackageTarball to find the entry file inside a tarball extracted before any install has run.

Errors

Every failure is a Schema.TaggedError routed with Effect.catchTag. Causes are preserved structurally on a Schema.Defect field — a PackageDecodeError hands you the SchemaError issue tree, not String(error).

| Tag | Means | | --- | ----- | | PackageJsonNotFoundError | No file at the path. Often not an error at all: fall back, or walk up. | | PackageJsonReadError | The file is there and could not be read. Carries path and the structural cause. | | PackageJsonParseError | The bytes are not JSON. Carries path and the SyntaxError. | | PackageDecodeError | The JSON is not a package.json. Carries the SchemaError cause with its issue tree. | | PackageJsonWriteError | The write failed. Narrowed to the filesystem failure only, never an encode error. | | PackageValidationError | One or more validation rules failed. Carries every failure, each with its rule name, message and JSON path. | | InvalidPackageNameError | A string does not satisfy npm's naming rules. Raised by Package.setName. | | InvalidSpdxLicenseError | A string is not a valid SPDX license expression. Raised by Package.setLicense. | | InvalidDependencySpecifierError | A string is not a recognized dependency specifier. Raised by DependencySpecifier.decode. | | UnresolvedEntryPointError | No entry point could be resolved from a manifest. Returned in a Result by resolveEntryPoint, never raised, with reason telling noConditionMatched, noRootExport and unsupportedExportsForm apart. |

Package.setVersion fails with InvalidVersionError from @effected/semver, which is where the version grammar lives.

Features

  • Package — the manifest model: typed known fields, the rest catch-all, computed getters (isPrivate, isScoped, isESM, hasDependency, the four get*Dependencies), dual mutation statics, copyWith, Package.decode and the pure toJsonString serializer.
  • Package.schema / Package.wireFor — the open-JSON ↔ class wire codec, and the factory that builds one for a .extend()ed subclass so its custom fields decode as typed members instead of falling into rest.
  • PackageJsonFile — the IO surface: read and write over core FileSystem / Path, with the platform implementation supplied at the edge.
  • PackageValidator — rule-based validation aggregating every failure, with the default rule set, a parameterized layerRules factory, and the publish-gate rules noUnresolvedDepsRule and noLocalDepsRule.
  • resolveEntryPoint — the pure, Result-returning entry-point resolver over a manifest's exports/main, honoring exports encapsulation rather than falling through to main, with EntryPointManifest as its tolerant input shape.
  • LenientManifest — the shape-lenient discovery tier below PackageManifest: malformed known fields degrade to absence, are preserved verbatim in rest and reported on issues, rather than failing the document; decodeResult/decode and parseResult/parse in the package's usual Result/Effect pairing.
  • Package.resolvecatalog: and workspace: expansion over the @effected/npm contracts with pnpm's publish-time projection (alias form included), as an explicit step that write never performs for you.
  • PackageName, DependencySpecifier, Dependency, SpdxLicense, PackageManager, Person, Repository, Bugs, Funding, DevEngine — the leaf concepts, each owning its own statics, brand and error, usable independently of Package. Repository and Bugs decode the repository and bugs fields from either their shorthand or object form, the same way Person does for author, contributors and maintainers; Repository also exposes browseUrl and gitUrl getters that normalize a shorthand or SSH form to https://.
  • Repository.directoryUrl — the browse URL of a monorepo member's own subdirectory, built from repository.directory against the host's path convention on GitHub, GitLab and Bitbucket. none for a host whose convention is unknown, or for a directory that climbs out of the repository — a guessed path would resolve to nothing while looking authoritative. With no directory, it is browseUrl.
  • Funding — the funding field, decoded from a bare URL string, an object, or an array of either, and always to an array so a consumer never branches on arity. Each entry re-encodes in the form it was read from, and unrecognized keys survive in rest.
  • licenseExpressionOf — a SpdxLicense as an Option<SpdxExpression> from @effected/spdx, for a caller that wants the parsed expression rather than the string. none for the SEE LICENSE IN … and UNLICENSED forms, which are valid license values but not license expressions.
  • Package also types keywords, maintainers and homepage directly, alongside the existing author and contributors.
  • Field schemas (DependencyMapField, BinField, ExportsField, PublishConfigField, PeerDependenciesMetaField, StringMapField) exported for subclasses that extend the model. RepositoryField is exported too but deprecated: Package.repository now decodes through Repository.FromValue, which round-trips the original shorthand or object form; RepositoryField remains only for consumers still matching on the raw union.

License

MIT