Skip to main content

usage-rules.md

# Enviable Usage Rules

Enviable is a small collection of functions to improve Elixir project
configuration via environment variables following the 12-factor application
model. It provides robust value conversion and works well with environment
loaders like Dotenvy, Nvir, or Envious.

## Core Principles

1. **Import in `config/runtime.exs`** - Standard location for runtime
   configuration
2. **Use specific conversion functions** - Prefer `fetch_env_as_integer!/1` over
   manual conversion
3. **Choose the right variant** - `fetch_*!` raises, `fetch_*` returns
   `{:ok, value} | :error`, `get_*` returns value or default
4. **Leverage type-specific functions** - Use `get_env_as_boolean/2`,
   `fetch_env_as_integer!/1`, etc.

## Decision Guide: When to Use What

### Choose Your Fetch Variant

**Use `fetch_env!/1` when:**

- Variable is required for application to run
- You want the application to crash immediately if missing
- No sensible default exists

**Use `fetch_env/1` when:**

- Variable is required but you want to handle absence explicitly
- You need pattern matching on `{:ok, value}` or `:error`
- Building conditional configuration logic

**Use `get_env/2` when:**

- Variable is optional
- You have a sensible default value
- Application can run when the result is `nil`

### Choose Your Conversion Function

**Use `fetch_env_as_TYPE!/1` when:**

- Variable is required AND needs type conversion
- You want immediate crash on missing or invalid value
- Examples: `fetch_env_as_integer!("PORT")`,
  `fetch_env_as_boolean!("ENABLE_SSL")`

**Use `fetch_env_as_TYPE/1` when:**

- Variable is required but you want explicit error handling
- Returns `{:ok, converted_value}` or `:error`
- Example: `fetch_env_as_integer("PORT")`

**Use `get_env_as_TYPE/2` when:**

- Variable is optional with a default
- Returns converted value or default
- Example: `get_env_as_integer("PORT", default: 4000)`

## Common Patterns

### Basic Configuration

```elixir
# config/runtime.exs
import Config
import Enviable

config :my_app,
  # Required values - crash if missing
  secret_key: fetch_env!("SECRET_KEY"),
  database_url: fetch_env!("DATABASE_URL"),
  
  # Required with conversion
  port: fetch_env_as_integer!("PORT"),
  
  # Optional with defaults
  ssl_enabled: get_env_as_boolean("SSL_ENABLED"),
  pool_size: get_env_as_integer("POOL_SIZE", default: 10),
  log_level: get_env_as_log_level("LOG_LEVEL", default: :info)
```

### With Environment Loaders

#### Using Nvir

```elixir
import Nvir
import Enviable

client = fetch_env!("CLIENT")
dotenv!([".env", ".env.#{client}"])

config :my_app,
  key: fetch_env!("SECRET_KEY"),
  port: fetch_env_as_integer!("PORT")
```

#### Using Dotenvy

```elixir
import Config
import Enviable

client = fetch_env!("CLIENT")
Dotenvy.source([".env", ".env.#{client}"], side_effect: &put_env/1)

config :my_app,
  key: fetch_env!("SECRET_KEY"),
  port: fetch_env_as_integer!("PORT")
```

**Important:** Dotenvy requires `side_effect: &put_env/1` because Enviable works
with the system environment table. If there is another side effect specified,
ensure that it eventually uses `System.put_env/1`.

#### Using Envious

```elixir
import Config
import Enviable

client = fetch_env!("CLIENT")
env_files = [".env", ".env.#{client}"]

loaded_env =
  Enum.reduce(env_files, %{}, fn file, acc ->
    with {:ok, contents} <- File.read(file),
         {:ok, env} <- Envious.parse(contents) do
      Map.merge(acc, env)
    else
      _ -> acc
    end
  end)

for {key, value} <- loaded_env, do: put_env_new(key, value)

config :my_app,
  key: fetch_env!("SECRET_KEY"),
  port: fetch_env_as_integer!("PORT")
```

### Type Conversions

#### Boolean Conversion

```elixir
# Only "1" and "true" return true by default (case-insensitive)
# All other values return false
# Default is false if unset
ssl_enabled: get_env_as_boolean("SSL_ENABLED")

# With explicit default
ssl_enabled: get_env_as_boolean("SSL_ENABLED", default: true)

# With custom truthy values (other values return false)
debug: get_env_as_boolean("DEBUG", truthy: ["enabled", "on"])

# With custom falsy values (other values return true)
debug: get_env_as_boolean("DEBUG", default: true, falsy: ["disabled", "off"])

# Note: Cannot specify both truthy and falsy
```

#### Integer Conversion

```elixir
# Base 10 (default)
port: fetch_env_as_integer!("PORT")

# Different bases
hex_value: get_env_as_integer("HEX_VALUE", default: 0, base: 16)
```

#### Atom Conversion

```elixir
# Unsafe - creates new atoms
env: get_env_as_atom("MIX_ENV", default: :dev)

# Safe - only existing atoms
env: get_env_as_safe_atom("MIX_ENV", default: :dev, allowed: [:dev, :test, :prod])
```

#### Module Conversion

```elixir
# Unsafe - creates new atoms
adapter: get_env_as_module("ADAPTER", default: MyApp.DefaultAdapter)

# Safe - only allowed modules
adapter: get_env_as_safe_module("ADAPTER", default: MyApp.DefaultAdapter,
  allowed: [MyApp.Adapter.Postgres, MyApp.Adapter.MySQL])
```

#### List Conversion

```elixir
# Comma-separated by default
hosts: get_env_as_list("HOSTS", default: ["localhost"])

# Custom delimiter
paths: get_env_as_list("PATHS", default: [], delimiter: ":")

# With type conversion
ports: get_env_as_list("PORTS", default: [], as: :integer)

# With complex type conversion
modules: fetch_env_as_list!("MODULES", as: :safe_module, allowed: [MyApp.A, MyApp.B])
```

### Chained Conversions

Base encoding and list conversions support an `:as` option to chain conversions:

```elixir
# Decode base64, then parse as JSON
config: fetch_env_as_base64!("CONFIG", as: :json)

# Decode base32, then convert to atom (unsafe)
name: fetch_env_as_base32!("NAME", as: :atom, downcase: true)

# Split list, then convert each element to integer
ports: fetch_env_as_list!("PORTS", as: :integer)

# Split list, then convert each to safe module
adapters: fetch_env_as_list!("ADAPTERS", as: :safe_module, 
  allowed: [MyApp.Adapter.A, MyApp.Adapter.B])

# Decode URL-safe base64, then parse as Elixir term
data: fetch_env_as_url_base64!("DATA", as: :elixir)
```

Available base encoding conversions with `:as`:

- `*_as_base16` - Base16/hex encoding
- `*_as_base32` - Base32 encoding
- `*_as_hex32` - Base32 hex encoding
- `*_as_base64` - Base64 encoding
- `*_as_url_base64` - URL-safe Base64 encoding

When using `:as`, you can also pass options for the target type:

```elixir
# Decode base64, parse as JSON with custom engine
config: fetch_env_as_base64!("CONFIG", as: :json, engine: Jason)

# Split list, convert to atoms with downcase
tags: fetch_env_as_list!("TAGS", as: :atom, downcase: true)
```

#### JSON Conversion

```elixir
# Uses configured JSON engine (Jason, JSON, :json, etc.)
config: get_env_as_json("APP_CONFIG", default: %{})

# Custom engine
config: get_env_as_json("APP_CONFIG", default: %{}, engine: Jason)
```

#### Timeout Conversion

```elixir
# Accepts timeout strings like "30s", "5m", "1h"
# Returns milliseconds as integer or :infinity
# Default is :infinity if unset
timeout: get_env_as_timeout("TIMEOUT")

# With explicit default (can be integer ms, :infinity, Duration, or keyword)
timeout: get_env_as_timeout("TIMEOUT", default: 5000)
timeout: get_env_as_timeout("TIMEOUT", default: "30s")
timeout: get_env_as_timeout("TIMEOUT", default: "PT30S")
timeout: get_env_as_timeout("TIMEOUT", default: Duration.new!(second: 30))
timeout: get_env_as_timeout("TIMEOUT", second: 30)
```

#### Duration Conversion

```elixir
# Accepts ISO8601 duration strings like "PT30S", "PT1H30M"
# Returns Duration struct
# Default is nil if unset
duration: get_env_as_duration("DURATION")

# With explicit default (can be Duration struct or ISO8601 string)
duration: get_env_as_duration("DURATION", default: "PT30S")
duration: get_env_as_duration("DURATION", default: Duration.new!(second: 30))
```

#### Base Encoding Conversions

```elixir
# Base16 (hex)
secret: fetch_env_as_base16!("SECRET_HEX")

# Base32 (standard alphabet: A-Z, 2-7)
token: fetch_env_as_base32!("TOKEN_B32")

# Base32 hex (extended hex alphabet: 0-9, A-V)
token: fetch_env_as_hex32!("TOKEN_HEX32", case: :lower)

# Base64
cert: fetch_env_as_base64!("CERTIFICATE")

# URL-safe Base64
key: fetch_env_as_url_base64!("API_KEY")
```

#### PEM Conversion

```elixir
# Parse PEM-encoded certificates/keys
cert: fetch_env_as_pem!("SSL_CERT")

# Filter specific entry types
cert: fetch_env_as_pem!("SSL_CERT", filter: :cert)
key: fetch_env_as_pem!("SSL_KEY", filter: :key)
```

### Conditional Configuration

```elixir
case fetch_env("FEATURE_FLAG") do
  {:ok, "enabled"} ->
    config :my_app, feature_enabled: true
  
  _ ->
    config :my_app, feature_enabled: false
end
```

### Setting Variables

```elixir
# Set unconditionally
put_env("MY_VAR", "value")

# Set only if not already set
put_env_new("MY_VAR", "default_value")

# Set multiple at once
put_env(%{"VAR1" => "value1", "VAR2" => "value2"})
```

## Available Conversion Types

| Type           | Function           | Description                                     |
| -------------- | ------------------ | ----------------------------------------------- |
| `:atom`        | `*_as_atom`        | Convert to atom (unsafe - creates new atoms)    |
| `:safe_atom`   | `*_as_safe_atom`   | Convert to existing atom only                   |
| `:boolean`     | `*_as_boolean`     | Convert to boolean                              |
| `:charlist`    | `*_as_charlist`    | Convert to charlist                             |
| `:decimal`     | `*_as_decimal`     | Convert to Decimal (requires `decimal` package) |
| `:duration`    | `*_as_duration`    | Convert to Duration                             |
| `:elixir`      | `*_as_elixir`      | Parse as Elixir term (unsafe)                   |
| `:erlang`      | `*_as_erlang`      | Parse as Erlang term (unsafe)                   |
| `:float`       | `*_as_float`       | Convert to float                                |
| `:integer`     | `*_as_integer`     | Convert to integer                              |
| `:json`        | `*_as_json`        | Parse as JSON                                   |
| `:list`        | `*_as_list`        | Split into list                                 |
| `:log_level`   | `*_as_log_level`   | Convert to Logger level atom                    |
| `:module`      | `*_as_module`      | Convert to module (unsafe - creates new atoms)  |
| `:safe_module` | `*_as_safe_module` | Convert to allowed module only                  |
| `:pem`         | `*_as_pem`         | Parse PEM-encoded data                          |
| `:timeout`     | `*_as_timeout`     | Convert to timeout                              |
| `:base16`      | `*_as_base16`      | Decode Base16/hex                               |
| `:base32`      | `*_as_base32`      | Decode Base32                                   |
| `:base64`      | `*_as_base64`      | Decode Base64                                   |
| `:url_base64`  | `*_as_url_base64`  | Decode URL-safe Base64                          |

## Configuration Options

### Boolean Downcase

Configure case-folding for boolean conversions:

```elixir
# config/config.exs
config :enviable, :boolean_downcase, :default  # or :ascii, :greek, :turkic
```

### JSON Engine

Configure the JSON parsing engine:

```elixir
# config/config.exs
config :enviable, :json_engine, Jason
# or
config :enviable, :json_engine, {Jason, :decode, [[floats: :decimals]]}
```

Default engines (in order of preference):

1. `JSON` (Elixir's built-in, if available)
2. `:json` (Erlang/OTP 27+)
3. `Jason` (fallback)

## Common Gotchas

1. **Atom Creation** - `*_as_atom` creates new atoms which are never garbage
   collected. Use `*_as_safe_atom` with `:allowed` option for user input.

2. **Module Creation** - `*_as_module` has the same atom creation issue. Use
   `*_as_safe_module` with `:allowed` option.

3. **Dotenvy Side Effects** - Must use `side_effect: &put_env/1` with Dotenvy
   for Enviable to see loaded variables.

4. **Boolean Defaults** - `get_env_as_boolean/2` returns `false` by default if
   unset. Only `"1"` and `"true"` (case-insensitive) return `true` by default.

5. **Integer Bases** - Base must be between 2 and 36 for `*_as_integer` with
   `:base` option.

6. **List Delimiters** - Default delimiter is comma. Use `:delimiter` option for
   other separators.

7. **Chained Conversions** - Base encoding and list functions support `:as`
   option to chain conversions (e.g., `fetch_env_as_base64!("VAR", as: :json)`).

8. **Decimal Dependency** - `*_as_decimal` functions require the `decimal`
   package to be installed.

## Function Reference

### Delegates to System

- `delete_env/1` - Delete environment variable
- `fetch_env/1` - Fetch variable, returns `{:ok, value} | :error`
- `fetch_env!/1` - Fetch variable, raises if missing
- `get_env/0` - Get all environment variables as map
- `get_env/2` - Get variable with default
- `put_env/1` - Set multiple variables from map
- `put_env/2` - Set single variable

### Enviable-Specific

- `put_env_new/2` - Set variable only if not already set

### Generic Conversion

- `get_env_as/3` - Get and convert with default
- `fetch_env_as/3` - Fetch and convert, returns `{:ok, value} | :error`
- `fetch_env_as!/3` - Fetch and convert, raises on error

### Type-Specific Conversions

Each type has three variants:

- `get_env_as_TYPE/2` - With default
- `fetch_env_as_TYPE/1` - Returns `{:ok, value} | :error`
- `fetch_env_as_TYPE!/1` - Raises on error

See "Available Conversion Types" table above for all supported types.

## Resources

- **[Hex Package](https://hex.pm/packages/enviable)** - Package on Hex.pm
- **[HexDocs](https://hexdocs.pm/enviable)** - Complete API documentation
- **[GitHub Repository](https://github.com/halostatue/enviable)** - Source code
  and issues
- **[12-Factor App](https://12factor.net/)** - Configuration methodology

## Performance Tips

1. **Minimize conversions** - Cache converted values rather than converting
   repeatedly
2. **Use specific functions** - `fetch_env_as_integer!/1` is more efficient than
   `fetch_env!/1` + manual conversion
3. **Avoid atom creation** - Use `*_as_safe_atom` and `*_as_safe_module` with
   `:allowed` lists
4. **Batch variable setting** - Use `put_env/1` with a map for multiple
   variables