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.
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.
}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)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 |
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"])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.
let assert Ok(maybe_port) = envie.optional("METRICS_PORT", decode.int())
// Ok(None) when missing, Ok(Some(value)) when present and validSupports 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'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. Use gleam test -- --seed 123 (or any seed) to run tests with a single worker if you encounter flaky tests.
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"
}
}| 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 |
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.
- Gleam 1.14 or newer.
- OTP 27+ on the BEAM.
- Just
gleam_stdlib— no runtime dependencies.
Made with Gleam 💜
