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
ConfigSourcecontract 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
- Run the examples
- Quick start: appsettings-style configuration
- Map environment overrides and validate settings
- Choose how to read environment variables
- Your proposed flow and the current API
- Select environments with strings or enums
- Choose fallback and strict behavior
- Combine sources and control precedence
- Read typed values and sections
- Bind models and inject settings
- Load native files and process variables
- Add a custom source
- Initialize a Flutter application
- Cache factories and rebuild configuration
- Override configuration in tests
- Handle failures and troubleshoot
- Resources and source layout
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 |
- Provider semantics and parser grammar
- Migration and compatibility
- Versioned capability roadmap
- API reference
- Release checks and measured validation results
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.