@effected/config-file

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

Composable config file loading for Effect: JSON, JSONC, YAML and TOML codecs, resolution strategies, and merge behaviors.

原始使用文档
文档目录9 节
  1. Why @effected/config-file
  2. Install
  3. Quick start
  4. Reading one known path
  5. Rejecting keys the schema does not know
  6. Errors
  7. Codecs
  8. Features
  9. License

@effected/config-file

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

Composable config file loading for Effect. Declare a resolver chain — an explicit path, an upward walk from the cwd, the workspace or git root, /etc — decode every discovered file through an Effect Schema, and combine the results with a merge strategy. JSON decodes out of the box; JSONC, YAML and TOML each take one optional engine package. Codecs, resolvers and merge strategies are pluggable seams, and failures arrive as tagged errors carrying structured payloads rather than prose, so "no config anywhere" is routable separately from "the config I found is broken".

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/config-file

Config loading is where a well-typed application usually gives up: a library finds a file, parses it, validates it, and reports every one of those distinct failures as the same opaque error with a reason string. This package refuses that. Discovery, reading, parsing, validation and persistence each fail with their own tagged error, and each carries its cause structurally — a ConfigValidationError hands you the schema issue tree, not String(ParseError). Resolver requirements flow into the layer's type rather than being cast away, the merge step reports every source that contributed rather than only the first, and a loaded document can be handed to Config accessors as a v4 ConfigProvider layered beneath the environment.

Install

npm install @effected/config-file effect @effect/platform-node
pnpm add @effected/config-file effect @effect/platform-node

Requires Node.js >=24.11.0. That install covers JSON. To read JSONC, YAML or TOML, add the matching engine:

pnpm add @effected/jsonc   # for @effected/config-file/jsonc
pnpm add @effected/yaml    # for @effected/config-file/yaml
pnpm add @effected/toml    # for @effected/config-file/toml

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 a peer dependency, and so are @effected/walker and @effected/glob — the traversal primitive the upwardWalk, workspaceRoot and gitRoot resolvers are built on, and the matcher it peers on. Package managers that install peers automatically will pull them in; add them to your manifest explicitly if yours does not. @effected/jsonc, @effected/yaml and @effected/toml are optional peers: only the ./jsonc, ./yaml and ./toml subpaths load them, and the package root never does, so a JSON-only program installs and loads none of them. Its one runtime dependency is @effected/files, which backs the atomic write, so nothing it drags into your tree comes from outside effect and @effected/*.

Reading and writing files needs a FileSystem and a Path implementation, provided once at the edge — from @effect/platform-node on Node.

Quick start

Declare a schema, mint a service class for it with ConfigFile.Service, and build its live layer with ConfigFile.layer. The platform layers are provided once, at the edge:

import { ConfigFile, ConfigResolver, JsonCodec, MergeStrategy } from "@effected/config-file";
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem";
import * as NodePath from "@effect/platform-node/NodePath";
import { Effect, Layer, Schema } from "effect";

class AppShape extends Schema.Class<AppShape>("AppShape")({
  port: Schema.Number,
  host: Schema.String,
}) {}

class AppConfig extends ConfigFile.Service<AppConfig, AppShape>()("app/Config") {}

const AppConfigLive = ConfigFile.layer(AppConfig, {
  schema: AppShape,
  codec: JsonCodec,
  resolvers: [ConfigResolver.upwardWalk({ filename: ".apprc" })],
  strategy: MergeStrategy.firstMatch<AppShape>(),
});

const program = Effect.gen(function* () {
  const config = yield* AppConfig;
  return yield* config.load;
});

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

Effect.runPromise(program.pipe(Effect.provide(AppConfigLive), Effect.provide(PlatformLive))).then(console.log);
// AppShape { port: 3000, host: "localhost" }

ConfigFile.layer is a layer-returning function, not a layer: calling it twice builds two independent service instances. Bind its result to a const, as above, and provide that const.

Resolvers are consulted in priority order, highest first. MergeStrategy.firstMatch takes the winner; MergeStrategy.layeredMerge deep-merges every source that matched, with higher-priority keys overwriting lower ones:

import { ConfigFile, ConfigResolver, MergeStrategy } from "@effected/config-file";
import { YamlCodec } from "@effected/config-file/yaml";
import { Schema } from "effect";

class Settings extends Schema.Class<Settings>("Settings")({ port: Schema.Number }) {}
class SettingsConfig extends ConfigFile.Service<SettingsConfig, Settings>()("app/Settings") {}

export const SettingsLive = ConfigFile.layer(SettingsConfig, {
  schema: Settings,
  codec: YamlCodec,
  resolvers: [
    ConfigResolver.upwardWalk({ filename: ".apprc.yaml" }),
    ConfigResolver.workspaceRoot({ filename: ".apprc.yaml" }),
    ConfigResolver.systemEtc({ app: "myapp", filename: "config.yaml" }),
  ],
  strategy: MergeStrategy.layeredMerge<Settings>(),
});

Reading one known path

Not every caller has a config file — some just have one path a caller already vouched for, such as a CLI's --config flag. ConfigFile.read is the one-shot escape from the service, the layer and the resolver chain: read, decode and validate a single path, with the schema and codec named per call rather than bound to a service class:

import { ConfigFile, JsonCodec } from "@effected/config-file";
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem";
import { Effect, Schema } from "effect";

class MyConfig extends Schema.Class<MyConfig>("MyConfig")({ port: Schema.Number }) {}

const program = ConfigFile.read("./app.config.json", { schema: MyConfig, codec: JsonCodec });

Effect.runPromise(program.pipe(Effect.provide(NodeFileSystem.layer))).then(console.log);
// MyConfig { port: 3000 }

It is deliberately read-only and discovery-free — no resolver chain, no save/update. Reach for ConfigFile.layer the moment either is wanted.

Rejecting keys the schema does not know

Effect's decoder ignores unknown keys by default, which for a config loader means a typo'd section is dropped in silence. The user gets no error, the setting they wrote has no effect, and nothing in the run says why. parseOptions threads decode options into every decode the loader performs, on ConfigFile.layer and ConfigFile.read alike:

import { ConfigFile, ConfigResolver, JsonCodec, MergeStrategy } from "@effected/config-file";
import { Schema } from "effect";

class Settings extends Schema.Class<Settings>("Settings")({ port: Schema.Number }) {}
class SettingsConfig extends ConfigFile.Service<SettingsConfig, Settings>()("app/Settings") {}

export const SettingsLive = ConfigFile.layer(SettingsConfig, {
  schema: Settings,
  codec: JsonCodec,
  resolvers: [ConfigResolver.upwardWalk({ filename: ".apprc" })],
  strategy: MergeStrategy.firstMatch<Settings>(),
  parseOptions: { onExcessProperty: "error", errors: "all" },
});
// A file carrying `{ "port": 3000, "prot": 3001 }` now fails with a
// ConfigValidationError whose issue tree names the offending path.

The validate option cannot stand in for this: it runs on the decoded value, by which point the excess keys are already gone. Pair onExcessProperty: "error" with errors: "all" — the decoder reports only the first problem otherwise, so a file with three typos costs the user three fix-and-rerun cycles. The extra work happens only on a document that is already failing.

A schema that deliberately admits a pass-through section keeps working under "error" — but know why, because the shape suggests the opposite: a Schema.StructWithRest rest switches excess checking off for that struct entirely, not merely for the keys the rest covers. Structs without a rest stay strict independently, so strictness is decided per level rather than per key. Omitting parseOptions changes nothing, which makes turning this on a per-loader decision rather than a migration.

Errors

Every failure is a tagged error you route on with Effect.catchTag. The tags exist so that recovery can differ:

TagMeansRecovery
ConfigFileNotFoundErrorThe resolver chain matched nothing. Carries searched, the resolver names probed, and candidates, the paths they actually checked on disk.Fall back to defaults — the one failure that is often not an error. loadOrDefault handles it for you.
ConfigFileReadErrorA file was found but could not be read. Carries path and the structural cause.Usually fatal: the file exists and the process cannot read it. Check permissions.
ConfigFileWriteErrorA file could not be written. Carries path and the structural cause.Retry elsewhere, or surface to the user.
ConfigDefaultPathMissingErrorsave or update was called on a service configured without a defaultPath.A wiring bug, not a data condition. Fix the layer, or call write with an explicit path.
ConfigValidationErrorThe document did not satisfy the schema, or a caller-supplied validate rejected it. Carries the structured issue tree and an optional path.Report the issue; do not run on config you could not validate.
ConfigCodecErrorThe codec could not parse or stringify. Carries codec, operation and the structural cause.The file is corrupt. Report the path and the cause.
ConfigMigrationErrorA versioned migration failed. Carries version, name, phase and the structural cause.Report which step failed; the config on disk is left untouched.
ConfigEncryptionErrorAn encrypt, decrypt, key-derivation or base64 step failed. Carries phase and the structural cause.A wrong passphrase and a corrupt envelope both land here; inspect phase.

ConfigLoadError, ConfigReadError, ConfigEncodeError, ConfigWriteError, ConfigSaveError and ConfigUpdateError are exported unions naming exactly the failures each method can produce — ConfigEncodeError is ConfigWriteError minus ConfigFileWriteError, because encode never touches the disk. Catching one tag narrows the union, leaving the rest to propagate:

import type { ConfigFileShape } from "@effected/config-file";
import { Effect, Schema } from "effect";

class AppShape extends Schema.Class<AppShape>("AppShape")({ port: Schema.Number }) {}

const fallback = new AppShape({ port: 3000 });

// `load` fails with ConfigLoadError. Handling the not-found tag leaves
// ConfigReadError — the file-is-broken failures, which we let propagate.
export const loadOrFallback = (config: ConfigFileShape<AppShape>) =>
  config.load.pipe(Effect.catchTag("ConfigFileNotFoundError", () => Effect.succeed(fallback)));

Codecs

Four codecs ship in the package, each a free-standing named export. JsonCodec comes from the package root; the other three each have their own subpath and an optional engine peer to install beside it:

CodecFormatImport fromEngine
JsonCodecJSON@effected/config-filethe host JSON global, no parser at all
JsoncCodecJSONC@effected/config-file/jsonc@effected/jsonc
YamlCodecYAML@effected/config-file/yaml@effected/yaml
TomlCodecTOML@effected/config-file/toml@effected/toml

You install and load only the parser you name. The root never reaches a subpath, so a JSON-only application installs no parser and ships none. Each subpath reaches only its own engine, and the codecs are never collected into a namespace object, so importing TomlCodec never references the YAML or JSONC bindings.

Codecs compose. EncryptedCodec wraps any codec with AES-GCM, and ConfigMigration.make wraps any codec so parsed content is brought up to the latest version. Each widens the error channel rather than flattening its failures into the inner codec's error, and a decorated codec plugs into ConfigFile.layer, ConfigFile.testLayer and ConfigFile.read like any other: their error unions widen with it. A service key names the widened error as its third type argument:

import type { ConfigCodec } from "@effected/config-file";
import {
  ConfigFile,
  ConfigMigration,
  ConfigResolver,
  EncryptedCodec,
  EncryptedCodecKey,
  JsonCodec,
  MergeStrategy,
} from "@effected/config-file";
import { Effect, Schema } from "effect";

const migrating = ConfigMigration.make({
  codec: JsonCodec,
  migrations: [
    {
      version: 2,
      name: "add-port",
      up: (raw) => Effect.succeed({ ...(raw as Record<string, unknown>), port: 8080 }),
    },
  ],
});

// `parse` now fails with ConfigCodecError | ConfigMigrationError | ConfigEncryptionError.
export const secret = EncryptedCodec(migrating, EncryptedCodecKey.fromPassphrase("hunter2", new Uint8Array(16)));

const Settings = Schema.Struct({ port: Schema.Number });
// Read back from the codec value: ConfigCodecError | ConfigMigrationError | ConfigEncryptionError.
type SecretError = ConfigCodec.ErrorOf<typeof secret>;

class SecretSettings extends ConfigFile.Service<SecretSettings, typeof Settings.Type, SecretError>()(
  "app/SecretSettings",
) {}

export const SecretSettingsLive = ConfigFile.layer(SecretSettings, {
  schema: Settings,
  codec: secret,
  resolvers: [ConfigResolver.explicitPath("./settings.enc")],
  strategy: MergeStrategy.firstMatch(),
});

Leave the third type argument off and the ConfigFile.layer call is a compile error: that service's methods would fail with a ConfigEncryptionError its type never names. The envelope's base64 is decoded by core's effect/encoding/Base64, so whitespace around it is tolerated while whitespace inside it, or missing padding, fails with ConfigEncryptionError phase "encoding".

Features

  • ConfigFile.Service / ConfigFile.layer / ConfigFile.testLayer — a per-schema service class and its layers. testLayer seeds files into a temp directory and wires the real implementation over them, so tests exercise the actual pipeline rather than a stub that can drift from it.
  • parseOptions — decode options threaded into every decode, on the layer and on read. onExcessProperty: "error" is the only way to report a typo'd section or a field the schema deliberately removed.
  • ConfigFile.read — the one-shot escape from the service: read, decode and validate one explicit path, schema and codec named per call, with no resolver chain and no write path.
  • encode / write / save / update — the write path. encode(value, options?) returns the serialized text a write would put on disk without writing, emitting an event or naming a path in its errors: the primitive behind a --dry-run flag. write takes an explicit path the caller vouches for; save and update resolve the layer's defaultPath and create its parent directory. encode and write accept { header }, text prepended verbatim above the document with exactly one newline between — a #:schema https://… directive atop a TOML file, say. The caller owns its validity in the target format; JSON has no comment syntax, so a header on JsonCodec yields an unparseable file by construction.
  • ConfigResolver — explicitPath, staticDir, upwardWalk, workspaceRoot, gitRoot and systemEtc. A resolver's error channel is never by contract: every filesystem failure becomes Option.none(), so one unreadable tier never aborts the chain.
  • MergeStrategy — firstMatch and layeredMerge, combining discovered sources in priority order.
  • JsonCodec (root), JsoncCodec (./jsonc), YamlCodec (./yaml), TomlCodec (./toml) — JSON, JSONC, YAML and TOML, exported free-standing and split by subpath so an unused format's engine is neither installed nor loaded.
  • ConfigCodec / EncryptedCodec / ConfigMigration — a pluggable codec seam, generic in its error type so decorators widen rather than flatten. ConfigCodec is the interface: bring your own format by satisfying it.
  • ConfigEvents — an opt-in PubSub of ConfigEvent, honestly zero-cost when omitted: no events option means no context lookup at all. Failure events carry the structured typed error, never a reason string.
  • asConfigProvider / layerConfigProvider — expose a loaded, validated document as a v4 ConfigProvider, layered beneath the ambient one so an environment variable overrides the file it was deployed with.

License

MIT

版本与发布记录

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

版本发布日期距今
0.28.0latest2026/11/13今天
0.27.12026/11/112 天前
0.27.02026/11/103 天前
0.26.02026/11/094 天前
0.25.02026/11/067 天前
0.24.02026/11/0310 天前

关于 @effected/config-file 的常见问题

@effected/config-file 是什么?

Composable config file loading for Effect: JSON, JSONC, YAML and TOML codecs, resolution strategies, and merge behaviors.

如何安装 @effected/config-file?

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

@effected/config-file 支持 TypeScript 吗?

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

@effected/config-file 支持 ESM 还是 CommonJS?

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

@effected/config-file 需要什么版本的 Node.js?

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

@effected/config-file 可以商用吗?

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

@effected/config-file 的下载量有多少?

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

@effected/config-file 还在维护吗?

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