envie
Type-safe environment variables for Gleam. Zero external dependencies, cross-platform (Erlang + JavaScript).
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. 🥐
envie gets out of your way: import it, call get(...), and you’re done.
When you need more typed parsing, validation, .env loading, test isolation,
it’s all there without changing the core workflow.
Quick start
gleam add envie
import 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 without boilerplate. When the variable is missing or the value cannot be parsed, you get the default back.
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 (case-insensitive):
| Truthy | Falsy |
|---|---|
true yes 1 on | false no 0 off |
Validated access
When a missing or malformed variable should be a hard error,
use require_*. Every function returns Result(value, Error)
with a structured error you can format for logs.
No extra imports — just envie:
import envie
let assert Ok(key) = envie.require_string("API_KEY")
let assert Ok(port) = envie.require_port("PORT") // 1–65 535
let assert Ok(url) = envie.require_web_url("DATABASE_URL") // requires http/https
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
If none of the built-in require_* functions covers your case,
import envie/decode and compose a custom decoder:
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)
This is the only scenario where you need envie/decode.
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
Supports comments (#), inline comments (PORT=8080 # default),
blank lines, export prefix, and single/double-quoted values.
By default, existing environment variables are not overwritten.
Use load_override / load_override_from when .env values should win.
let assert Ok(Nil) = envie.load() // .env in cwd
let assert Ok(Nil) = envie.load_from("config/.env.local") // custom path
let assert Ok(Nil) = envie.load_from_string("PORT=8080") // from string
// Force overwrite
let assert Ok(Nil) = envie.load_override()
Example .env file:
# Application
PORT=8080
DEBUG=true
# Secrets
export API_KEY="sk-1234567890"
DB_PASSWORD='hunter2'
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
}
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 💜