@effected/package-json

中文文档
0.32.1发布于 2026/11/11MITTypeScriptES Module构建来源已验证近 30 天下载 1.1万

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

原始使用文档
文档目录13 节
  1. Why @effected/package-json
  2. Install
  3. Quick start
  4. The Package model
  5. Reading and writing
  6. Validation
  7. Resolving catalog: and workspace: specifiers
  8. Lenient discovery
  9. Resolving an entry point
  10. Resolving any subpath, imports and types
  11. Errors
  12. Features
  13. License

@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-1.0.0. This package is part of the @effected/* kit, built on stable Effect v4 (effect ~4.1.0) and still in 0.x development. Stable Effect makes a kit 1.0.0 possible, not automatic. To keep your effect and @effect/* versions on the line the kit is built and tested against, install @effected/pnpm-plugin-effected.

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, @effected/npm and @effected/files come along as ordinary dependencies — they back the version field, the resolver contracts and the atomic write — 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 next.version.gt(other) 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 PackageFormatOptions — indent, 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 * as NodeFileSystem from "@effect/platform-node/NodeFileSystem";
import * as NodePath from "@effect/platform-node/NodePath";
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.

Resolving any subpath, imports and types

PackageExports is Node's resolver algorithm over a manifest: any subpath, # imports, * patterns, null exclusions, array fallbacks, and TypeScript's reading of the same fields. Every static is a synchronous Result primitive with an Effect twin:

import { PackageExports } from "@effected/package-json";
import { Option, Result } from "effect";

const manifest = {
 exports: {
  ".": { types: "./index.d.ts", import: "./index.js" },
  "./*": { types: "./types/*.d.ts", import: "./dist/*.js" },
  "./internal/*": null,
 },
};

const http = PackageExports.resolveResult(manifest, "./Http");
console.log(Result.isSuccess(http) && http.success.path);
// => ./dist/Http.js

const hidden = PackageExports.resolveResult(manifest, "./internal/secret");
console.log(Result.isFailure(hidden) && hidden.failure.code);
// => ERR_PACKAGE_PATH_NOT_EXPORTED

const typed = PackageExports.resolveTypesResult(manifest, "./Http");
console.log(Result.isSuccess(typed) && Option.getOrNull(typed.success.declaration));
// => ./types/Http.d.ts

conditions is a set, not a priority list: the manifest's own key order decides, as it does in Node and TypeScript, so a types key written after import is never reached. splitSpecifier("@scope/pkg/sub") gives the name and subpath, entriesResult and typesEntriesResult enumerate everything a package exports, and PackageJsonFile.exportedFiles(directory, manifest) expands pattern exports against the files on disk.

Every answer stays inside the package. A target that does not start with ./, or that carries a .., ., empty or node_modules segment (percent-encoded or not, \ included), fails typed with PackageExportsError, and a * match cannot inject one either: "./*": "./dist/*.js" resolves ./a and refuses ./%2e%2e/secret.

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).

TagMeans
PackageJsonNotFoundErrorNo file at the path. Often not an error at all: fall back, or walk up.
PackageJsonReadErrorThe file is there and could not be read. Carries path and the structural cause.
PackageJsonParseErrorThe bytes are not JSON. Carries path and the SyntaxError.
PackageDecodeErrorThe JSON is not a package.json. Carries the SchemaError cause with its issue tree.
PackageJsonWriteErrorThe write failed. Narrowed to the filesystem failure only, never an encode error.
PackageValidationErrorOne or more validation rules failed. Carries every failure, each with its rule name, message and JSON path.
InvalidPackageNameErrorA string does not satisfy npm's naming rules. Raised by Package.setName.
InvalidSpdxLicenseErrorA string is not a valid SPDX license expression. Raised by Package.setLicense.
InvalidDependencySpecifierErrorA string is not a recognized dependency specifier. Raised by DependencySpecifier.decode.
UnresolvedEntryPointErrorNo 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.resolve — catalog: 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

版本与发布记录

@effected/package-json 共发布 58 个版本,首次发布于 2026/07/17;过去 12 个月发布了 58 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.32.1latest2026/11/112 天前
0.32.02026/11/102 天前
0.31.22026/11/103 天前
0.31.12026/11/094 天前
0.31.02026/11/094 天前
0.30.02026/11/067 天前

其他发布通道:next → 0.18.0-next-20260927071821。

关于 @effected/package-json 的常见问题

@effected/package-json 是什么?

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

如何安装 @effected/package-json?

在项目目录运行 npm install @effected/package-json,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:effect(~4.1.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@effected/package-json 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@effected/package-json 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@effected/package-json 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=24.11.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@effected/package-json 可以商用吗?

@effected/package-json 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@effected/package-json 的下载量有多少?

根据 npm 官方下载统计,@effected/package-json 在 2026/10/13 – 2026/11/11 这 30 天内被下载 10,588 次,日均约 353 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@effected/package-json 还在维护吗?

最新版本 0.32.1 发布于 2026/11/11(2 天前);至今共发布 58 个版本,其中过去 12 个月发布了 58 个;首次发布于 2026/07/17。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。