env.cr
env
Declarative, type-safe configuration from environment variables for Crystal.
Annotate getter fields with @[Env::Field], and env generates a typed loader that reads from ENV, converts values, validates required fields, and exposes immutable getters.
Installation
-
Add the dependency to your
shard.yml:dependencies: env: github: threez/env.cr -
Run
shards install
Usage
The library supports two modes of operation: ENV-only for pure twelve-factor-style configuration, and ENV + file for projects that keep a local config file with per-environment defaults.
ENV-only
The simplest mode. Annotate each getter with @[Env::Field(env: "VAR_NAME")] and set a Crystal default value for optional fields:
require "env"
class MyApp::Config
include Env::Configurable
@[Env::Field(env: "PORT")]
getter port : Int32 = 8080
@[Env::Field(env: "DATABASE_URL")]
getter database_url : String
@[Env::Field(env: "DEBUG")]
getter debug : Bool = false
@[Env::Field(env: "SECRET")]
getter secret : String?
@[Env::Field(env: "ALLOWED_ORIGINS")]
getter allowed_origins : Array(String) = [] of String
end
config = MyApp::Config.load
config.port # => 8080
config.debug # => false
config.secret # => nil (not set)
config.production? # => false
This is the right choice when all configuration comes from the process environment (containers, systemd units, PaaS platforms, CI, etc.).
ENV + config file (YAML or JSON)
File format support is opt-in. Add the appropriate require before using path: with load:
require "env/yaml" # enables .yml / .yaml
require "env/json" # enables .json
Pass a path: to load to enable a file-based fallback. Files are structured with top-level keys for each environment (development, production, ...) and field names as nested keys:
require "env"
require "env/yaml"
class MyApp::Config
include Env::Configurable
@[Env::Field(env: "PORT")]
getter port : Int32 = 8080
@[Env::Field(env: "DATABASE_URL")]
getter database_url : String = "postgres://localhost/myapp"
@[Env::Field(env: "KMS_URL")]
getter kms_url : String = "http://localhost:3000"
@[Env::Field(env: "JWT_SECRET")]
getter jwt_secret : String?
@[Env::Field(env: "RATE_LIMIT")]
getter rate_limit : Int32 = 30
end
config = MyApp::Config.load(path: "data/config.yml")
# data/config.yml
development:
database_url: postgres://localhost/myapp_dev
kms_url: http://localhost:3000
production:
database_url: postgres://db.internal/myapp
kms_url: https://kms.internal
rate_limit: 100
Or with JSON:
require "env"
require "env/json"
config = MyApp::Config.load(path: "data/config.json")
{
"development": {
"database_url": "postgres://localhost/myapp_dev",
"kms_url": "http://localhost:3000"
},
"production": {
"database_url": "postgres://db.internal/myapp",
"kms_url": "https://kms.internal",
"rate_limit": 100
}
}
The environment section is selected by the APP_ENV environment variable (defaulting to "development"). Pass env: to override:
config = MyApp::Config.load(path: "config.yml", env: "production")
The config file path is fully runtime-configurable — read it from an environment variable, a CLI flag, or any other source:
config = MyApp::Config.load(path: ENV.fetch("CONFIG_FILE", "config.yml"))
Pass nil (or omit path:) to disable file loading and use only ENV and field defaults:
config = MyApp::Config.load # ENV + defaults only
config = MyApp::Config.load(path: nil) # same
Custom sources
For formats beyond YAML and JSON, subclass Env::Source and pass an instance to load via source::
class TomlSource < Env::Source
def initialize(path : String, env : String)
# parse file, select the env section
end
def get(option : Env::Option) : String?
# look up field by option.name, return raw string or nil
end
end
config = MyApp::Config.load(source: TomlSource.new("config.toml", "production"))
When source: is given it takes precedence over path:. The resolution chain is always: ENV > custom source > defaults.
Source chain and loading priority
Configuration is resolved by querying a chain of Env::Source instances in order. The first source to return a non-nil value wins.
The chain is:
Env::EnvSource— reads process environment variables- File or custom source —
Env::YamlSource(opt-in viarequire "env/yaml"),Env::JsonSource(opt-in viarequire "env/json"), or a customEnv::Source(whenpath:orsource:is passed toload) Env::DefaultSource— returns the field declaration default value
If no source provides a value: nilable types get nil, required fields raise Env::MissingVariableError.
Field annotation options
| Option | Description | Required |
|---|---|---|
env |
Environment variable name to read | yes |
separator |
Delimiter for Array fields (default ",") |
no |
description |
Human-readable description (supports ${ENV}, ${TYPE}, ${DEFAULT}) |
no |
The default value is declared directly on the getter using Crystal's standard ivar default syntax (= value).
Supported types
| Type | ENV conversion |
|---|---|
String |
Used as-is |
String? |
nil when not set |
Bool |
"true", "1", "yes" (case-insensitive) are true |
Int8 .. Int64 |
Parsed with .to_i8 .. .to_i64 |
UInt8 .. UInt64 |
Parsed with .to_u8 .. .to_u64 |
Float32, Float64 |
Parsed with .to_f32, .to_f64 |
Array(String) |
Split by separator, stripped, empty entries rejected |
Array(Int32), etc. |
Split then parsed per element type |
Array(Float64), etc. |
Split then parsed per element type |
Required vs optional fields
A field's behavior when no value is found depends on its type and whether an ivar default is declared:
# Required — raises Env::MissingVariableError if not set anywhere
@[Env::Field(env: "API_KEY")]
getter api_key : String
# Optional with default — uses the default when not set
@[Env::Field(env: "PORT")]
getter port : Int32 = 3000
# Nilable — returns nil when not set
@[Env::Field(env: "SECRET")]
getter secret : String?
Array fields
Array fields split the raw string by separator (default ","), strip whitespace from each entry, and reject empty entries. The element type determines how each entry is parsed:
# String arrays
@[Env::Field(env: "TAGS")]
getter tags : Array(String) = [] of String
@[Env::Field(env: "ORIGINS", separator: "|")]
getter origins : Array(String) = [] of String
# Integer arrays
@[Env::Field(env: "PORTS")]
getter ports : Array(Int32) = [] of Int32
# Float arrays
@[Env::Field(env: "WEIGHTS")]
getter weights : Array(Float64) = [] of Float64
export TAGS="web, api, backend" # => ["web", "api", "backend"]
export ORIGINS="http://a.com|http://b.com" # => ["http://a.com", "http://b.com"]
export PORTS="80, 443, 8080" # => [80, 443, 8080]
export WEIGHTS="1.5, 2.75, 0.5" # => [1.5, 2.75, 0.5]
Supported element types: String, Int8..Int64, UInt8..UInt64, Float32, Float64.
Introspection and CLI help
Fields can include a description in the annotation for documentation and CLI help output. Descriptions support ${ENV}, ${TYPE}, and ${DEFAULT} template variables that are substituted automatically:
class MyApp::Config
include Env::Configurable
@[Env::Field(env: "PORT", description: "Listen port for the HTTP server")]
getter port : Int32 = 8080
@[Env::Field(env: "DATABASE_URL", description: "Database connection URL (${TYPE})")]
getter database_url : String
@[Env::Field(env: "DEBUG", description: "Enable debug mode (default: ${DEFAULT})")]
getter debug : Bool = false
@[Env::Field(env: "SECRET", description: "Optional secret for ${ENV}")]
getter secret : String?
end
self.options returns an Array(Env::Option) with structured metadata for each field (name, env var, type, default, required flag, description):
MyApp::Config.options.each do |opt|
puts "#{opt.env} (#{opt.type}) — #{opt.description}"
end
self.help prints a formatted table to any IO (default STDOUT):
MyApp::Config.help
Environment variables:
PORT Int32 8080 Listen port for the HTTP server
DATABASE_URL String (required) Database connection URL (String)
DEBUG Bool false Enable debug mode (default: false)
SECRET String? (nil) Optional secret for SECRET
You can also call Env.help(options, io:) directly with any options array.
Environment helpers
Every config class gets production? and development? instance methods that check APP_ENV:
config = MyApp::Config.load
config.production? # => true when APP_ENV == "production"
config.development? # => true when APP_ENV is "development" or not set
Testing
In tests, set the relevant ENV vars and call load normally:
with_env({"DATABASE_URL" => "postgres://localhost/test"}) do
config = MyApp::Config.load
config.database_url # => "postgres://localhost/test"
config.port # => 8080 (uses declared default)
end
Development
make # run fmt, lint, docs, and spec
make fmt # format code
make lint # run ameba linter
make spec # run tests
make docs # generate API docs
Contributing
- Fork it (https://github.com/threez/env.cr/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
Contributors
- Vincent Landgraf - creator and maintainer
env.cr
- 0
- 0
- 0
- 0
- 1
- about 3 hours ago
- May 18, 2026
MIT License
Tue, 19 May 2026 14:34:57 GMT