envie
Why the name?
envywas already taken on Hex, so we went withenvie. Because you shouldn't be jealous of other languages' config loaders β you should just desire (French: envie) a better one. Plus, it makes your environment variables feel 20% more sophisticated. π₯
Type-safe environment configuration for Gleam. Cross-platform and zero runtime dependencies.
envie is simple at first, and scales cleanly as your application grows. From single variables to fully structured configuration.
What envie helps you do
- read and write env vars without ceremony
- parse values into real types
- keep configuration explicit and predictable
- scale from simple defaults to structured config
- inspect what actually happened at runtime
Quick start
gleam add envieimport envie
pub fn main() {
let port = envie.get_int("PORT", 3000)
let debug = envie.get_bool("DEBUG", False)
// That's it. No setup, no ceremony.
}Core API
Read, write, and remove environment variables on any target.
envie.get("HOME") // -> Ok("/Users/you")
envie.set("APP_ENV", "production")
envie.unset("TEMP_TOKEN")
let all = envie.all() // -> Dict(String, String)Type-safe getters
Parse common types with defaults.
let port = envie.get_int("PORT", 3000)
let ratio = envie.get_float("RATIO", 1.0)
let debug = envie.get_bool("DEBUG", False)
let origins = envie.get_string_list("ORIGINS", separator: ",", default: [])Boolean parsing accepts:
true,yes,1,onfalse,no,0,off
Validated access
Use require_* when missing or invalid values should fail.
import envie
let assert Ok(key) = envie.require_string("API_KEY")
let assert Ok(port) = envie.require_port("PORT")
let assert Ok(url) = envie.require_web_url("DATABASE_URL")
let assert Ok(name) = envie.require_non_empty_string("APP_NAME")
let assert Ok(on) = envie.require_bool("DEBUG")
let assert Ok(ratio) = envie.require_float_range("RATIO", min: 0.0, max: 1.0)
let assert Ok(env) = envie.require_one_of("APP_ENV", ["development", "staging", "production"])Custom validation
Compose a decoder with envie/decode when built-in checks are not enough.
import envie
import envie/decode
import gleam/string
let secret_decoder =
decode.string()
|> decode.validated(fn(s) {
case string.length(s) >= 32 {
True -> Ok(s)
False -> Error("Secret must be at least 32 characters")
}
})
let assert Ok(secret) = envie.require("JWT_SECRET", secret_decoder)Optional variables
let assert Ok(maybe_port) = envie.optional("METRICS_PORT", decode.int())
// Ok(None) when missing, Ok(Some(value)) when present and valid.env file loading
Load .env files with comments, quoted values, and export prefixes.
By default, existing environment values are preserved.
Use load_override / load_override_from to overwrite.
let assert Ok(Nil) = envie.load()
let assert Ok(Nil) = envie.load_from("config/.env.local")
let assert Ok(Nil) = envie.load_from_string("PORT=8080")
let assert Ok(Nil) = envie.load_override()
Example .env file:
# Application
PORT=8080
DEBUG=true
# Secrets
export API_KEY="sk-1234567890"
DB_PASSWORD='hunter2'When you need more than defaults
As your app grows, configuration tends to spread across modules and become harder to validate and reason about.
envie/schema keeps everything in one place.
import envie/schema
import envie/decode
pub type Config {
Config(
port: Int,
debug: Bool,
)
}
let config_schema =
schema.build2(
schema.field("PORT", decode.int())
|> schema.default(3000),
schema.field("DEBUG", decode.bool())
|> schema.default(False),
Config,
)
let assert Ok(config) = schema.load(config_schema)This gives you:
- one place for config declarations
- typed parsing and validation
- explicit defaults and optional values
Multiple environments
For more complex setups (multiple environments, overrides, deployments), you can load multiple .env files with explicit priority.
import envie/dotenv
let assert Ok(Nil) =
dotenv.load([
dotenv.override(".env"),
dotenv.optional(".env.local"),
dotenv.required(".env.production"),
])overrideoverwrites existing valuesoptionalis ignored if missingrequiredfails if missing
Debugging configuration
When configuration becomes non-trivial, itβs often unclear:
- which values were actually read
- which defaults were applied
- where a failure happened
envie/inspect lets you trace configuration loading:
import envie/inspect
let trace = inspect.load_with_trace(config_schema)This is especially useful in debugging production issues or validating deployments.
Testing utilities
Helpers that guarantee the environment is restored after each test.
import envie
import envie/testing
pub fn my_feature_test() {
testing.with_env([#("PORT", "3000"), #("DEBUG", "true")], fn() {
let port = envie.get_int("PORT", 8080)
port |> should.equal(3000)
})
// Original environment is restored automatically
}
pub fn isolated_test() {
testing.isolated(fn() {
envie.get("PATH") |> should.equal(Error(Nil))
})
// Everything restored
}[!WARNING] Test Concurrency: Environment variables are global to the process. Since Gleam runs tests in parallel by default, multiple tests using
testing.*concurrently may interfere with each other. Usegleam test -- --seed 123(or any seed) to run tests with a single worker if you encounter flaky tests.
Error formatting
Every error type carries enough context to produce clear messages.
case envie.require_int("PORT") {
Ok(port) -> start_server(port)
Error(err) -> {
io.println_error(envie.format_error(err))
// "PORT: invalid value "abc" β Expected integer, got: abc"
}
}API at a glance
| Function | Returns | Notes |
|---|---|---|
get | Result(String, Nil) | Raw access |
set | Nil | |
unset | Nil | |
all | Dict(String, String) | |
get_string | String | Falls back to caller-supplied default |
get_int | Int | Falls back to caller-supplied default |
get_float | Float | Falls back to caller-supplied default |
get_bool | Bool | Falls back to default; true/yes/1/on |
get_string_list | List(String) | Falls back to default; splits & trims |
require | Result(a, Error) | Decoder-based |
require_string | Result(String, Error) | |
require_int | Result(Int, Error) | |
require_int_range | Result(Int, Error) | |
require_float | Result(Float, Error) | |
require_float_range | Result(Float, Error) | |
require_url | Result(Uri, Error) | Permissive RFC parse |
require_url_with_scheme | Result(Uri, Error) | e.g. ["postgres"] |
require_web_url | Result(Uri, Error) | http or https only |
require_non_empty_string | Result(String, Error) | |
require_string_prefix | Result(String, Error) | |
require_string_list | Result(List(String), Error) | |
require_int_list | Result(List(Int), Error) | |
require_bool | Result(Bool, Error) | true/yes/1/on |
require_port | Result(Int, Error) | 1β65 535 |
require_one_of | Result(String, Error) | Allow-list check |
optional | Result(Option(a), Error) | |
load | Result(Nil, LoadError) | .env in cwd |
load_from | Result(Nil, LoadError) | Custom path |
load_from_string | Result(Nil, LoadError) | From string |
load_override | Result(Nil, LoadError) | Overwrites env |
load_override_from | Result(Nil, LoadError) | Overwrites env |
load_from_string_override | Result(Nil, LoadError) | Overwrites env |
Cross-platform
envie works on Erlang and all major JavaScript runtimes.
- Erlang/OTP β uses
os:getenv/1,os:putenv/2,os:unsetenv/1. - JavaScript (Node.js, Bun) β uses
process.envandfsmodule. - JavaScript (Deno) β uses
Deno.envandDeno.readTextFileSync. - JavaScript (Browser) β environment variables and
.envloading are safely disabled (returningError) without crashing your build or runtime.
Dependencies & Requirements
- Gleam 1.14 or newer.
- OTP 27+ on the BEAM.
-
Just
gleam_stdlibβ no runtime dependencies.
Made with Gleam π