envx

Load a base JSON file, layer environment-specific settings over it, and read the section your application needs. envx supports Dart and Flutter without global state or code generation.

The library provides:

  • Typed environment factories with enum aliases, fallback, and optional caching.
  • An ordered builder for memory, JSON, dotenv, native files, and process values.
  • Immutable merged values with primitive getters and section access.
  • Application-owned typed models and validation before startup completes.
  • A small ConfigSource contract for your own loaders, without runtime dependencies.

Version 2.0.0 adds layered configuration providers while preserving the existing environment API. Existing 1.0.0 consumers can follow the migration guide. See the changelog for release details and the validation record for quality checks.

Contents

Install and check compatibility

The examples below target 2.0.0. To use the repository checkout in a Dart or Flutter application:

dependencies:
  envx:
    path: ../dart_envx

Run dart pub get, or flutter pub get in a Flutter application. Once 2.0.0 is available on pub.dev, use the hosted dependency instead:

dependencies:
  envx: ^2.0.0
Requirement Support
Dart SDK >=3.0.0 <4.0.0; locally checked on 3.0.7 and 3.13.4
Flutter SDK Not required by the package; Flutter apps can consume the core
Runtime dependencies None
Core import package:envx/envx.dart — native and web
Native providers package:envx/envx_io.dart — accessible files and process variables

The native entry point also exports the core, so use one import for a native application. Do not import src/ files. Native filesystem paths are different from Flutter asset paths. Validation results distinguish local checks from the broader CI matrix.

Run the examples

From the repository root:

dart pub get
dart run example/minimal_example.dart
dart run example/envx_example.dart
dart run -DAPP_ENV=production example/envx_example.dart
dart run example/configuration_example.dart
Example Demonstrates
Minimal quick start Two sources, precedence, typed reads; no setup beyond dependencies
Main package example Enum selection, custom JSON loading, merging, typed models, validation, dotenv parsing, optional values, caching
Native providers JSON overlays, dotenv files, process overrides, HTTPS validation, constructor injection

The main example is an optional feature tour: it reads no files and makes no network calls. For a simple JSON setup, start with the appsettings quick start below. It is referenced by example: example/envx_example.dart in the package's pubspec. That field is custom descriptive metadata; pub.dev finds the Example tab through conventional paths such as example/envx_example.dart.

With no overrides, the native example selects development and prints API: https://dev.example.com; timeout: 15s. Its public dotenv fixture overrides the JSON timeout of 30. A local .env or process variable can override it again. Unknown selections in the main example fail because it enables strict mode.

Quick start: appsettings-style configuration

For a native Dart application, use a .NET-style appsettings flow: two files and one build:

import 'package:envx/envx_io.dart';

const env =
    String.fromEnvironment('ENVIRONMENT', defaultValue: 'development');

Future<void> main() async {
  final settings = await EnvxBuilder()
      .addJsonFile('example/config/appsettings.json')
      .addJsonFile('example/config/appsettings.$env.json', optional: true)
      .buildValues();

  final api = settings.getSection('api');
  print(api.getString('baseUrl'));
  print(api.getInt('timeoutSeconds'));
}

Save this as example/appsettings_demo.dart and run it from the repository root:

dart run -DENVIRONMENT=production example/appsettings_demo.dart

The paths above use the repository's existing JSON fixtures. In your own native application, replace them with your files, such as config/appsettings.json.

Base file:

{
  "api": {
    "baseUrl": "https://api.example.com",
    "timeoutSeconds": 30
  }
}

Development overlay:

{
  "api": {
    "baseUrl": "https://dev.example.com"
  }
}

The result uses the development URL and keeps the base timeout of 30. Later sources override earlier values. An absent optional overlay is skipped. An unreadable or malformed file still throws; optional: true does not suppress all failures.

To get the section's raw map, use the existing plural property:

final apiSettings = settings.getSection('api').values;

addJsonFile() reads native filesystem files. A path beginning with assets/ is still a filesystem path to this provider; it does not load Flutter's bundled assets. For a Flutter application, use the asset-loading recipe.

Map environment overrides and validate settings

For your API_URL and API_TIMEOUT inputs, use the existing addEnvironmentVariables() method with an explicit mapping. This complete native example builds on the same two JSON files:

import 'dart:io';
import 'package:envx/envx_io.dart';

const env =
    String.fromEnvironment('ENVIRONMENT', defaultValue: 'development');

Future<void> main() async {
  final apiUrl = Platform.environment['API_URL'];
  final apiTimeout = Platform.environment['API_TIMEOUT'];

  final settings = await EnvxBuilder()
      .addJsonFile('example/config/appsettings.json')
      .addJsonFile('example/config/appsettings.$env.json', optional: true)
      .addEnvironmentVariables(environment: {
        if (apiUrl != null) 'api__baseUrl': apiUrl,
        if (apiTimeout != null) 'api__timeoutSeconds': apiTimeout,
      })
      .build(ConfigValues.new, validate: SettingsValidator.validate);

  final api = settings.getSection('api');
  print(api.getString('baseUrl'));
  print(api.getInt('timeoutSeconds'));
}

class SettingsValidator {
  static void validate(ConfigValues settings) {
    final api = settings.getSection('api');
    final url = Uri.tryParse(api.getString('baseUrl'));
    if (url == null || url.scheme != 'https' || url.host.isEmpty) {
      throw const ConfigValidationException('API URL must use HTTPS.');
    }
    if (api.getInt('timeoutSeconds') <= 0) {
      throw const ConfigValidationException('API timeout must be positive.');
    }
  }
}

Missing variables are omitted, so they do not overwrite the JSON values. An explicitly empty string is an override, not an absent variable; it will fail this validator when it is not a valid URL or timeout. Do not put null into an explicit override map to mean “skip”: null normally replaces an earlier value.

__ maps to nested keys. Use api__Url instead of api__baseUrl if your JSON key is literally Url: keys are case-sensitive. Values remain strings until a typed getter or model binder converts them.

With the code saved as example/appsettings_demo.dart, run on macOS/Linux:

API_URL=https://api.example.com API_TIMEOUT=10 dart run -DENVIRONMENT=production example/appsettings_demo.dart

If you control the process variable names, the shorter version is .addEnvironmentVariables(prefix: 'APP__') with variables named APP__api__baseUrl and APP__api__timeoutSeconds. No explicit map is then needed.

There are two existing build choices:

Need Final builder call
Raw configuration with typed getters .buildValues()
Raw configuration with validation .build(ConfigValues.new, validate: SettingsValidator.validate)
Your own model with optional validation .build(AppSettings.fromJson, validate: validateSettings)

The last row uses an application-defined model and validator; see typed model binding. Validation completes before settings are returned. There is no separate addValidator() in the current API.

Choose how to read environment variables

Dart has two different environment mechanisms. Choose the one that matches your application:

Source Read Where it works
Compiler definition const String.fromEnvironment('ENVIRONMENT', defaultValue: 'development') Dart and Flutter, including web
Native process variable Platform.environment['ENVIRONMENT'] ?? 'development' Native Dart; not Flutter web

For a native server configured entirely through its process environment, replace the const declaration with the second form. For Flutter builds, keep the const form and run:

flutter run --dart-define=ENVIRONMENT=production

The one-line const expression is the simplest reliable compiler lookup. A function such as readEnvironment('ENVIRONMENT') cannot reliably forward an arbitrary runtime key to String.fromEnvironment in compiled Dart. var env = String.fromEnvironment(...) without a const invocation is not a portable shortcut.

For Flutter compiler overrides, declare the values once in application code:

const env =
    String.fromEnvironment('ENVIRONMENT', defaultValue: 'development');
const apiUrl = bool.hasEnvironment('API_URL')
    ? String.fromEnvironment('API_URL')
    : null;
const apiTimeout = bool.hasEnvironment('API_TIMEOUT')
    ? String.fromEnvironment('API_TIMEOUT')
    : null;

Then add this source after your JSON/asset sources, using the cross-platform core builder:

.addMemory({
  'api': {
    if (apiUrl != null) 'baseUrl': apiUrl,
    if (apiTimeout != null) 'timeoutSeconds': apiTimeout,
  },
})

Here addMemory() simply adds the provided Dart map. The presence checks make missing definitions leave earlier JSON values intact, including in Flutter web. This snippet is a step in a builder chain, not a standalone statement.

Your proposed flow and the current API

Existing public function names are unchanged. Translate the proposed setup as follows:

Proposed spelling / behavior Current envx API
addJson(path) addJsonFile(path) for native files; addSource(...) with your loader for Flutter assets
Optional environment JSON addJsonFile(path, optional: true) skips missing files only
addFromEnvironment(path, name) addEnvironmentVariables(environment: mappedValues) for process variables, or addMemory(...) for const compiler values
addValidator(validator) build(binder, validate: validator)
BuildValues() buildValues(); Dart uses a lowercase method name
getSection('api').value getSection('api').values, or typed getters on the section
addLiveSetting(url, refresh: ...) Future capability; not implemented in 2.0.0

A five-minute HTTP refresh is not a current builder option. buildValues() returns an immutable result, not a live settings object. A custom HTTP source can fetch once during a build, and another explicit build can fetch again; that does not provide polling, change notifications, or lifecycle management automatically.

A future live-settings adapter must validate before publishing each update, retain the last valid settings on failure, expose changes, and support disposal. If a remote source is last in the chain as in your proposal, its values would have highest precedence. The roadmap tracks this work; no live methods are presented as available APIs here.

Select environments with strings or enums

Use environment factories when each environment has its own configuration object or asynchronous initialization. Read compiler keys at a const call site:

const selected =
    String.fromEnvironment('APP_ENV', defaultValue: 'development');

final environments = Environment.register(
  configuration: {
    'development': () async => 'https://dev.example.com',
    'production': () async => 'https://api.example.com',
  },
  defaultEnvironment: 'development',
  environment: selected,
);
final baseUrl = await environments.current();

String keys need no resolver. For enum keys, provide aliases or your own resolver:

enum AppEnvironment { development, production }

final environments = Environment.register(
  configuration: {
    AppEnvironment.development: () async => 'https://dev.example.com',
    AppEnvironment.production: () async => 'https://api.example.com',
  },
  defaultEnvironment: AppEnvironment.development,
  resolver: aliasesResolver({
    AppEnvironment.development: ['dev', 'local'],
    AppEnvironment.production: ['prod', 'live'],
  }, strictCollisions: true),
  environment: 'prod',
);
final production = await environments.current();
final development = await environments.getEnvironment('local');
final explicit = await environments.getEnvironmentByKey(AppEnvironment.production);

Aliases are case-insensitive and recognize enum names by default. Set caseInsensitive: false or allowEnumName: false to change those rules. strictCollisions: true rejects a normalized alias mapped to different values; without it, the last assignment wins for compatibility.

For an existing token map, replace the resolver with:

final resolver = tokenResolver<AppEnvironment>({
  'dev': AppEnvironment.development,
  'prod': AppEnvironment.production,
}, strictCollisions: true);

A custom EnvResolver<K> returns a key or null for unknown input. Resolver exceptions propagate. Custom compiler keys work the same way: read const String.fromEnvironment('MY_ENV') and pass its value as environment. The deprecated dynamic defineKey option is not reliable in compiled AOT code.

Choose fallback and strict behavior

Operation / option Behavior
current() Resolves explicit environment, or fixed const APP_ENV, and loads configuration
currentKey() Resolves the key without loading its factory
getEnvironment(raw) Resolves a string through the configured resolver
getEnvironmentByKey(key) Looks up the typed key directly
Default selection behavior Unknown or missing selections/configurations fall back to the default
strict: true Requires a registered default; rejects missing/unrecognized selection and unregistered configuration lookups
Legacy return type Future<C>?; the future can be absent when neither requested nor default configuration exists
buildValues() / build<T>() Required Future<ConfigValues> / Future<T>; initialization failures throw

Enable strict: true on registration when an environment typo should stop startup. Check nullable legacy results before using them. A throwing factory is an error; it does not trigger fallback to another factory.

The native example treats the selected value as an overlay filename. Its optional missing overlay leaves the base settings intact; that is a separate policy from strict environment registration.

Combine sources and control precedence

Builder order is the complete precedence rule: later sources win.

final values = await EnvxBuilder()
    .addMemory({
      'api': {'host': 'base.example.com', 'timeout': 30},
      'regions': ['west', 'east'],
      'label': 'default',
    })
    .addMemory({
      'api': {'timeout': 10},
      'regions': ['central'],
      'label': null,
    })
    .buildValues();
Path Result Rule
api.host base.example.com Unchanged nested keys survive
api.timeout 10 Later scalar replaces earlier value
regions ['central'] Lists replace wholesale
label null Explicit null replaces, rather than deletes

Objects merge recursively. A scalar can replace a map, and a map can replace a scalar. Keys are case-sensitive: api and API are different. Inputs are copied; result maps and lists are deeply immutable. No conventional filenames are loaded unless explicitly added to the builder.

Read typed values and sections

final values = ConfigValues({
  'api': {'timeout': '15', 'enabled': 'true', 'ratio': '0.5'},
  'label': null,
  'literal.key': 'hello',
});
final api = values.getSection('api');
final timeout = api.getInt('timeout'); // 15
final enabled = api.getBool('enabled'); // true
final ratio = api.getDouble('ratio'); // 0.5
final literal = values.getString(r'literal\.key'); // hello
final label = values.get('label') ?? 'Untitled';
final hasLabel = values.contains('label'); // true, even though it is null
final hasOther = values.contains('other'); // false
Getter Accepts
getString(path) Strings only
getInt(path) Integers or decimal integer strings; no rounding of doubles
getDouble(path) Finite numbers or finite numeric strings
getBool(path) Booleans or case-insensitive true / false strings
getSection(path) An object, returned as another ConfigValues
get(path) Any stored value; null for absent or explicitly null
contains(path) Presence, including explicit null

Required getters reject missing/null values and incompatible types. Paths use dots; escape literal dots as \. and backslashes as \\. Lists are returned as values, not traversed by numeric path segments. Access values.values for the immutable raw map. Full conversion and path rules are in the provider guide.

Bind models and inject settings

Keep the model in your application. Use the binder to convert strings from dotenv or process providers, then validate before constructing dependent services:

class ApiSettings {
  const ApiSettings(this.baseUrl, this.timeoutSeconds);
  final String baseUrl;
  final int timeoutSeconds;

  factory ApiSettings.fromJson(Map<String, Object?> json) {
    final api = ConfigValues(json).getSection('api');
    return ApiSettings(api.getString('baseUrl'), api.getInt('timeoutSeconds'));
  }
}

class ApiService {
  const ApiService(this.settings);
  final ApiSettings settings;
}

Future<ApiService> createService() async {
  final settings = await EnvxBuilder()
      .addMemory({
        'api': {'baseUrl': 'https://api.example.com', 'timeoutSeconds': '30'},
      })
      .build(ApiSettings.fromJson, validate: (settings) {
        if (settings.timeoutSeconds <= 0) {
          throw const ConfigValidationException('API timeout must be positive.');
        }
      });
  return ApiService(settings);
}

build infers the model type. It also accepts your generated fromJson factory when its signature matches. Binders receive a deeply immutable map; copy it first if a serializer needs mutable input. Prefer immutable model fields so settings remain stable after startup. No DI framework is required; an existing container can register the completed model or service after await createService().

Load native files and process variables

Use package:envx/envx_io.dart for these providers:

import 'package:envx/envx_io.dart';

const selected =
    String.fromEnvironment('APP_ENV', defaultValue: 'development');

Future<void> main() async {
  final values = await EnvxBuilder()
      .addJsonFile('example/config/appsettings.json')
      .addJsonFile('example/config/appsettings.$selected.json', optional: true)
      .addDotEnvFile('example/config/overrides.env', prefix: 'APP__')
      .addDotEnvFile('.env', optional: true, prefix: 'APP__')
      .addEnvironmentVariables(prefix: 'APP__')
      .buildValues();
  print(values.getString('api.baseUrl'));
  print(values.getInt('api.timeoutSeconds'));
}

Run from the repository root to use the included fixtures. JSON roots must be objects. Files are UTF-8; relative paths resolve when the source is registered. optional: true skips only missing files, not malformed content, invalid encoding, directories, or permission failures.

A dotenv file can contain:

# Public example values, not secrets.
APP__api__timeoutSeconds=15
APP__features__analytics=false

The APP__ prefix is removed and __ creates nested objects. Thus APP__api__timeoutSeconds becomes api.timeoutSeconds. Both prefix and separator are configurable. Case is preserved; BASE_URL is not guessed to mean baseUrl. Scalar/object collisions such as APP__api plus APP__api__timeout fail.

Dotenv supports comments, empty values, export, and single/double quotes. Double quotes support common escapes; interpolation and shell execution are not performed. Use parseDotEnv(text) and nestEnvironmentValues(...) when the text already comes from another loader, as the main example demonstrates.

Override the native example on macOS/Linux:

APP__api__timeoutSeconds=5 dart run -DAPP_ENV=production example/configuration_example.dart

Or in PowerShell:

$env:APP__api__timeoutSeconds = '5'
dart run -DAPP_ENV=production example/configuration_example.dart
Remove-Item Env:APP__api__timeoutSeconds

APP_ENV above is a compiler setting; APP__api__timeoutSeconds is a process variable. Reading process variables does not subscribe to changes in another shell.

Add a custom source

Adapt an existing text loader with the single-method ConfigSource contract:

class JsonTextSource implements ConfigSource {
  const JsonTextSource(this.read);
  final Future<String> Function() read;

  @override
  Future<Map<String, Object?>> load() async => parseJsonConfig(await read());
}

Future<ConfigValues> loadSettings() {
  return EnvxBuilder()
      .addMemory({'timeout': 30})
      .addSource(JsonTextSource(() async => '{"timeout":10}'))
      .buildValues();
}

The main example runs this pattern. A source returns a complete JSON-compatible map on each load. Its owner controls resource cleanup, transport timeouts, and safe error messages. Use a loader your application already owns for remote data or Flutter assets; 2.0.0 does not include a first-party HTTP or asset adapter.

Initialize a Flutter application

For Flutter, use rootBundle.loadString through a custom ConfigSource. The following is application code using the existing public API, not a new envx asset method:

import 'package:envx/envx.dart';
import 'package:flutter/services.dart';

class JsonAssetSource implements ConfigSource {
  const JsonAssetSource(this.path);
  final String path;

  @override
  Future<Map<String, Object?>> load() async =>
      parseJsonConfig(await rootBundle.loadString(path));
}

const env =
    String.fromEnvironment('ENVIRONMENT', defaultValue: 'development');

Future<ConfigValues> loadSettings() {
  return EnvxBuilder()
      .addSource(const JsonAssetSource('assets/appsettings.json'))
      .addSource(JsonAssetSource('assets/appsettings.$env.json'))
      .buildValues();
}

At the beginning of your existing Flutter main():

WidgetsFlutterBinding.ensureInitialized();
final settings = await loadSettings();
final apiSettings = settings.getSection('api').values;
// Pass settings to your existing app/services, then call runApp as usual.

WidgetsFlutterBinding comes from package:flutter/widgets.dart (also exported by material.dart). Declare the files in the consuming application's pubspec:

flutter:
  assets:
    - assets/appsettings.json
    - assets/appsettings.development.json
    - assets/appsettings.production.json

Create both overlays; an unused overlay can contain {}. This minimal custom asset source treats both selected files as required. Native optional: true does not apply to Flutter assets. If your application needs missing-asset handling, implement that policy explicitly in its custom source; do not catch all parse or validation failures and silently continue.

Add the compiler override map after the asset sources if needed. Use build(ConfigValues.new, validate: ...) for raw validated settings or build(AppSettings.fromJson, validate: ...) for a model. Await loading before using settings. The core works on Flutter web and does not require dart:io.

Bundled assets and compiler definitions require rebuild/deployment to change. Keep secrets out of client-visible configuration, including downloaded JSON. The package's own pubspec does not declare your application's assets.

Cache factories and rebuild configuration

For legacy factories, caching is opt-in:

var calls = 0;
final environments = Environment.register(
  configuration: {'dev': () async => ++calls},
  defaultEnvironment: 'dev',
  environment: 'dev',
  cache: true,
);
await environments.current(); // 1
await environments.current(); // 1: same cached result
// Invalidate when the application explicitly chooses to refresh.
environments.clearCache();
await environments.current(); // 2

Concurrent reads share the pending factory when caching is enabled. Failures are evicted and can be retried. Clearing the cache does not cancel already returned futures. Without caching, each lookup invokes the factory again.

Builders have different semantics: concurrent builds on the same unchanged source list share source loading, while each caller binds/validates independently. A build after completion loads sources again. Reading an already returned model or ConfigValues performs no I/O. MemoryConfigSource copies its input at registration; mutating the original map will not update later builds.

There are no watchers, automatic reloads, or OptionsMonitor in 2.0.0. Applications explicitly replace their settings only after a new build succeeds. Future lifecycle APIs are listed in the roadmap.

Override configuration in tests

Use memory as the final provider for a deterministic override:

final values = await EnvxBuilder()
    .addMemory({'api': {'baseUrl': 'https://api.example.com', 'timeout': 30}})
    .addMemory({'api': {'baseUrl': 'https://test.example.com', 'timeout': 1}})
    .buildValues();
assert(values.getInt('api.timeout') == 1);

For native process-provider tests, inject a map rather than modifying the process:

// Import package:envx/envx_io.dart for this extension.
final values = await EnvxBuilder()
    .addEnvironmentVariables(
      prefix: 'APP__',
      environment: {'APP__api__timeout': '5'},
    )
    .buildValues();
assert(values.getInt('api.timeout') == 5);

Create independent builders or registries per test. No global reset is needed.

Handle failures and troubleshoot

Catch configuration exceptions at your application startup boundary. For example:

try {
  final values = await EnvxBuilder()
      .addMemory({'port': '8080'})
      .buildValues();
  print(values.getInt('port'));
} on ConfigException catch (error) {
  // Report a safe diagnostic and stop startup or apply your explicit policy.
  print(error.message);
  rethrow;
}
Symptom Explanation / action
Unexpected environment Check the const compiler key and aliases; opt into strict selection
Required value is null/missing Use contains/get for optional values or provide a default source
Conversion fails Dotenv/process values are strings; use typed getters or explicit binder conversion
Optional file still throws Optional skips absence only; fix malformed content or permissions
Browser cannot import native providers Import envx.dart and supply a web-compatible custom source
File changes are not reflected Build again explicitly; existing values do not reload themselves
A factory is called repeatedly Enable cache: true if that factory should be cached
A model fails validation Correct the input; validation runs before the model is returned

Typed errors include ConfigSourceException, ConfigParseException, ConfigMissingException, ConfigTypeException, ConfigBindingException, and ConfigValidationException. Invalid path syntax throws ArgumentError; legacy strict registration/lookups can throw StateError. Parser and binder diagnostics omit values. Custom provider errors and explicit validation messages are your application's responsibility. See error contracts.

Resources and source layout

Location Purpose
lib/envx.dart Cross-platform public API
lib/envx_io.dart Native providers and fluent extensions
example/ Runnable quick start, feature tour, native configuration fixtures
test/ Compatibility, parsing, merging, validation, concurrency, and native I/O tests
tool/compile_smoke.dart AOT/JavaScript compiler-selection regression check

The hosted API reference reflects the latest docs deployment. Run dart doc for this checkout. The implementation measured 160/160 PANA points, with all public API elements documented; pub.dev recalculates its score after publication.

Libraries

envx
Layered configuration and typed environment selection for Dart and Flutter.
envx_io
Native filesystem and process-environment adapters for envx.