@effected/config-file
v0.5.0
Published
Composable config file loading for Effect: JSON, JSONC, YAML and TOML codecs, resolution strategies, and merge behaviors.
Maintainers
Readme
@effected/config-file
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, JSONC, YAML and TOML all decode with no extra install. 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-release. This package is part of the
@effected/*kit, in pre-1.0.0development against a single pinned Effect v4 prerelease. Packages graduate to1.0.0once Effect4.0.0ships. To hold your owneffectversions 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.xreleases. Pin an exact version — even a package marked stable before1.0.0can 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-nodepnpm add @effected/config-file effect @effect/platform-nodeRequires Node.js >=24.11.0. Every format is covered by that one install; there is no separate package to add for YAML or 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/jsonc, @effected/toml, @effected/walker and @effected/yaml — the first-party engines behind the JSONC, YAML and TOML codecs, plus the traversal primitive the upwardWalk, workspaceRoot and gitRoot resolvers are built on. Package managers that install peers automatically will pull them in; add them to your manifest explicitly if yours does not. The package declares no runtime dependencies of its own, 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 { NodeFileSystem, NodePath } from "@effect/platform-node";
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, YamlCodec } 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: 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 { NodeFileSystem } from "@effect/platform-node";
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:
| Tag | Means | Recovery |
| --- | --- | --- |
| ConfigFileNotFoundError | The resolver chain matched nothing. Carries searched, the resolver names probed. | Fall back to defaults — the one failure that is often not an error. loadOrDefault handles it for you. |
| ConfigFileReadError | A 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. |
| ConfigFileWriteError | A file could not be written. Carries path and the structural cause. | Retry elsewhere, or surface to the user. |
| ConfigDefaultPathMissingError | save 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. |
| ConfigValidationError | The 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. |
| ConfigCodecError | The codec could not parse or stringify. Carries codec, operation and the structural cause. | The file is corrupt. Report the path and the cause. |
| ConfigMigrationError | A versioned migration failed. Carries version, name, phase and the structural cause. | Report which step failed; the config on disk is left untouched. |
| ConfigEncryptionError | An 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, ConfigWriteError, ConfigSaveError and ConfigUpdateError are exported unions naming exactly the failures each method can produce. 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:
| Codec | Format | Engine |
| ----- | ------ | ------ |
| JsonCodec | JSON | the host JSON global, no parser at all |
| JsoncCodec | JSONC | @effected/jsonc |
| YamlCodec | YAML | @effected/yaml |
| TomlCodec | TOML | @effected/toml |
One install covers every format, and you still pay only for the parser you name. The codecs are free-standing exports rather than properties of a namespace object, so importing TomlCodec never references the YAML or JSONC bindings, their parsing engines are unreachable from your entrypoint and a bundler drops them. A JSON-only application ships no parser at all.
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:
import { ConfigMigration, EncryptedCodec, EncryptedCodecKey, JsonCodec } from "@effected/config-file";
import { Effect } 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)));Features
ConfigFile.Service/ConfigFile.layer/ConfigFile.testLayer— a per-schema service class and its layers.testLayerseeds 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 onread.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.ConfigResolver—explicitPath,staticDir,upwardWalk,workspaceRoot,gitRootandsystemEtc. A resolver's error channel isneverby contract: every filesystem failure becomesOption.none(), so one unreadable tier never aborts the chain.MergeStrategy—firstMatchandlayeredMerge, combining discovered sources in priority order.JsonCodec,JsoncCodec,YamlCodec,TomlCodec— JSON, JSONC, YAML and TOML in the box, exported free-standing so an unused format's engine is tree-shaken away.ConfigCodec/EncryptedCodec/ConfigMigration— a pluggable codec seam, generic in its error type so decorators widen rather than flatten.ConfigCodecis the interface: bring your own format by satisfying it.ConfigEvents— an opt-inPubSubofConfigEvent, honestly zero-cost when omitted: noeventsoption means no context lookup at all. Failure events carry the structured typed error, never areasonstring.asConfigProvider/layerConfigProvider— expose a loaded, validated document as a v4ConfigProvider, layered beneath the ambient one so an environment variable overrides the file it was deployed with.
