<p align="center">
<img src="https://raw.githubusercontent.com/lupodevelop/envie/91a8dd6/assets/img/envie.png" alt="envie logo" width="200" />
</p>
[](https://hex.pm/packages/envie)[](https://hexdocs.pm/envie/)[](https://gleam.run)[](https://opensource.org/licenses/MIT)
# envie
Type-safe environment variables for Gleam.
Zero external dependencies, cross-platform (Erlang + JavaScript).
> Why the name? `envy` was already taken on Hex, so we went with `envie`.
> 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
```sh
gleam add envie
```
```gleam
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.
```gleam
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.
```gleam
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`:
```gleam
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:
```gleam
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
```gleam
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.
```gleam
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:
```env
# 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.
```gleam
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.
## Error formatting
Every error type carries enough context to produce clear messages.
```gleam
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.env` and `fs` module.
- **JavaScript (Deno)** — uses `Deno.env` and `Deno.readTextFileSync`.
- **JavaScript (Browser)** — environment variables and `.env` loading are safely disabled (returning `Error`) without crashing your build or runtime.
## Dependencies & Requirements
---
* Gleam **1.14** or newer.
* OTP 27+ on the BEAM.
* Just `gleam_stdlib` — no runtime dependencies.
---
<p align="center">Made with Gleam 💜</p>