@seateddotcom/varlock-elixir-plugin
v0.1.0
Published
Varlock plugin that adds @generateElixirEnv: a typed, coerced Elixir env module generated from your .env.schema
Readme
varlock-elixir-plugin
A varlock plugin that adds a @generateElixirEnv root decorator. It generates a typed, coerced Elixir module from your .env.schema, like the built-in @generatePythonEnv, @generateGoEnv and other language generators.
# @plugin(./varlock-elixir-plugin.cjs)
# @generateElixirEnv(path=./lib/my_app/env.ex)
# ---
# @type=port
PORT=4000
# @type=enum(debug, info, warning, error)
LOG_LEVEL=info
# @sensitive @type=url(allowedProtocols=[postgres])
DATABASE_URL=env = MyApp.Env.load!()
env.port #=> 4000 (integer(), not "4000")
env.log_level #=> :info (:debug | :info | :warning | :error)
inspect(env) #=> #MyApp.Env<port: 4000, log_level: :info, ...> (sensitive fields hidden)How it works
varlock 1.23+ lets plugins register code generators with plugin.registerCodeGenerator(). The built-in language generators and the official Effect plugin use the same API. This plugin registers generateElixirEnv. On varlock load, varlock run or varlock codegen, varlock passes it the resolved schema (keys, coerced types, required and sensitive flags, docs), and the plugin writes an Elixir module that:
- defines a struct with one snake_cased field per item (
DATABASE_URL→:database_url),@enforce_keysfor required items, and a full@type t - loads the
__VARLOCK_ENVJSON blob thatvarlock runinjects, throughload/0,load!/0(which raisesMyApp.Env.LoadError) orfrom_blob/1(useful in tests) - loads plain env vars without varlock, through
load_from_system_env/0andload_from_system_env!/0, for production deploys that set env vars directly (see Running without varlock) - validates every value against its type, returning
{:error, "PORT must be an integer"}. Error messages never include the value, because it may be sensitive. - turns string enum members into atoms (
"us-east-1"→:"us-east-1") - hides
@sensitiveitems frominspect/1with@derive {Inspect, except: ...}, redacts their values from any term withredact/2and from every log event onceinstall_log_filter!/2addslog_filter/2to:logger(see Redaction), and exposessensitive_keys/0andsensitive_values/1for your own leak scanning - maps each struct field back to its env key with
env_keys/0(%{database_url: "DATABASE_URL", ...}), so tooling that seesenv.database_urlin your code (a usage audit, say) can tell which variable it reads. varlock's ownvarlock auditdoesn't read this map; it's there for scripts and future tooling - includes each item's description,
@docslinks, deprecation and allowed values in the@moduledoc
The output is already mix format-clean and compiles with --warnings-as-errors, so a committed generated file passes typical CI checks and varlock run doesn't rewrite it. It is laid out for the :line_length in the nearest .formatter.exs above the output file (98, mix format's default, when none sets it), or for lineLength= on the decorator. See example/lib/example/env.ex for a full generated module.
Compatibility
The generated code needs Elixir 1.18+ for the built-in JSON module. With json=Jason it also works on older versions, though only 1.19 and 1.20 are tested. The suite runs against Elixir 1.20.4 (OTP 29) and 1.19.6 (OTP 27). On both, the generated module is mix format-clean, compiles with --warnings-as-errors, and passes the loader tests. The type-inference benefits below need 1.20; on 1.19 the module works the same but the compiler doesn't flag misuse.
Elixir 1.20 type checking
Elixir 1.20 infers types from guards, patterns and struct literals, with no type signatures needed. The generated loader is written so those inferred types reach your code:
- Top-level values are matched with an inline guard or literal (
when is_integer(value),{:ok, "info"} -> {:ok, :info}). - Strings, integers, numbers, booleans and free-form maps share one reader per type and requiredness (
blob_required_integer(values, "PORT"),system_optional_string("NAME", nil)), so a schema of mostly plain strings stays short. Inference carries a private function's return type to each call, so each field keeps its exact type. Enums, lists and typed maps get their own functions, which carry their member types. - Each nesting level of a list or map gets its own function (
cast_hosts(value),cast_hosts(:item, value)). Inference stops at a function that calls itself, or at a function passed in as an argument. - Lists are validated first, then built with a comprehension that unwraps
{:ok, item} = cast_hosts(:item, item), so each item keeps its type. - Maps with a known key set (
record(T, keyType=enum(...))) are built key by key withMap.put/3.
So the compiler knows each field's concrete type wherever load!/0 or load/0 is called, including list items and fixed-key map values:
def oops, do: MyApp.Env.load!().port <> "/x"
def oops2 do
[feature | _] = MyApp.Env.load!().enabled_features
feature <> "x"
endwarning: incompatible types in binary construction:
# type: integer()
warning: incompatible types in binary construction:
# type: dynamic(:"beta-search" or :dark_mode or :new_checkout)The test suite checks that the compiler reports these:
- binary construction on an integer field
String.upcase/1on an enum- arithmetic on a boolean
- typos in field names
- misused list items (enum and string)
- misused fixed-key map values
Some limits come from Elixir 1.20 itself:
- Maps with open keys (
record(T)withoutkeyType=enum(...),simple-object): values are validated at load time and declared in@type t, but Elixir doesn't track value types throughfor ... into: %{},Map.neworMap.filter, so the compiler sees them asdynamic(). - Possibly-nil optional fields: the compiler only warns when a type can never fit, and
binary() or niloverlaps withbinary(). That's Elixir's gradual-typing design, and it should change once Elixir gets explicit type signatures. Enumfunctions don't pass item types through, becauseEnumworks on any enumerable. Pattern matches and comprehensions over a field do keep them.
Type mapping
| varlock coerced type | Elixir type | notes |
| --- | --- | --- |
| string (url, email, …) | String.t() | |
| int (port, number(isInt=true)) | integer() | |
| number | float() | always a float: JSON 1 becomes 1.0 |
| boolean | boolean() | |
| enum(...) | @type <field>_value :: :a \| :b \| 1 \| true | string members → atoms; float members → float() |
| array(T) | [T] | |
| record(T) | %{optional(String.t()) => T} | keys stay strings |
| simple-object, record() | map() | |
| optional item | T \| nil | |
An enum stays as strings if any member is "true", "false" or "nil", or is longer than 255 characters, because those can't be distinct atoms. enums=strings keeps all enums as strings.
Keys that aren't valid field names (dotted.key, dashed-key, __STRUCT__) are left out of the struct and listed in the moduledoc. They're still injected as raw environment variables. Two keys that map to the same field name (API_KEY and apiKey) are a generation error.
Options
| option | default | |
| --- | --- | --- |
| path | (required) | output file, must end in .ex |
| module | derived from path | lib/my_app/env.ex → MyApp.Env; outside lib/ the file name is used (env.ex → Env) |
| json | JSON | JSON (built into Elixir 1.18+) or Jason |
| enums | atoms | atoms or strings |
| lineLength | from .formatter.exs | mix format line length to lay the file out for. By default the plugin reads a literal line_length: <n> from the nearest .formatter.exs above the output file, falling back to 98. Set this when the value is computed there |
| auto, filter, executeWhenImported | | shared by all varlock generators, see the code generation guide |
Using it in a Mix project
Build the plugin (
npm run build→dist/plugin.cjs) and copy that single file into your project, or install@seateddotcom/varlock-elixir-pluginfrom npm and use@plugin(@seateddotcom/varlock-elixir-plugin). With the standalone varlock binary, npm plugins need an exact version:@plugin(@seateddotcom/[email protected]).Add the decorators to
.env.schemaand runvarlock codegenonce. Commit the generated file, or add it to.gitignoreand letvarlock runregenerate it.Run your app under varlock so the blob is injected:
varlock run -- mix phx.server,varlock run -- iex -S mix, and so on.Read config where you need it.
config/runtime.exsis a good place (seeexample/config/runtime.exs):# config/runtime.exs if System.get_env("__VARLOCK_ENV") do env = MyApp.Env.load!() config :my_app, MyApp.Repo, url: env.database_url, pool_size: env.pool_size config :logger, level: env.log_level endThe guard lets plain
mix testrun without varlock. Tests can build envs withfrom_blob/1or a%MyApp.Env{}literal.If production runs without varlock, load from the plain env vars there instead:
# config/runtime.exs env = if config_env() == :prod, do: MyApp.Env.load_from_system_env!(), else: MyApp.Env.load!()
varlock run --inject vars omits the blob, and @encryptInjectedEnv encrypts it. Both stop the generated loader from working. load/0 returns a clear error in each case.
Running without varlock
Some deploys don't run varlock: a container platform sets plain env vars and starts the release directly. load_from_system_env/0 and load_from_system_env!/0 load the same struct from those vars. They read each schema item with System.get_env/1 and coerce the string, with the same types, enum atoms and error messages as load/0 (errors name the variable, never the value).
| schema type | accepted strings |
| --- | --- |
| string (url, email, …), enum with no members | any value, as is |
| int (port, number(isInt=true)) | an optional sign and digits; surrounding whitespace is ignored |
| number | an integer or an Elixir float such as 1.5, -2.0e3 or 1e3, always read as a float ("3" → 3.0) |
| boolean | varlock's set, in any case, whitespace ignored: true t yes on 1 / false f no off 0 |
| enum(...) | a member, matched exactly. Number and boolean members match their string form (2 by "2", true by "true"); string members win a tie, as in varlock |
| array(T) | a JSON array (starts with [), or items split on the array's separator= (, by default), trimmed, empty items dropped, each item parsed as T. Nested lists and maps need the JSON form |
| record(...), simple-object | a JSON object (starts with {) |
- Unset and empty values. An unset required item is an error (
PORT is required but not set in the environment), and so is an empty one (... but empty in the environment). An unset optional item isnil. An empty optional item isnil, except that an optional string keeps"", as it does in the blob. - Required in some environments.
@required=forEnv(staging, production)makes an item required only when the env flag (@currentEnv=$APP_ENV) is one of those values, and@optional=forEnv(test)makes it required everywhere else. varlock gives the plugin only whether an item is always required, so the plugin reads the literalforEnv(...)arguments from the env graph. Once every item is read,load_from_system_env/0checks them against the env flag's value, or its literal in.env.schemawhen it is unset, and fails withDB_URL is required in staging but not set in the environment. The struct type keeps these items asT | nil. Requiredness from anything else, such as@required=$FLAGornot(...), is left to varlock. - Defaults. An unset item falls back to its value in
.env.schemawhen that value is a plain literal (PORT=4000,LOG_LEVEL=info) and the item is not sensitive. The plugin reads this from varlock's env graph: it takes the highest-priority definition from aschema-type file that isn't env-specific, and uses its value only if varlock marks the resolver static. Values from.env,.env.local,.env.<env>and other value files are never used, and neither are resolver results (op(...),exec(...),$OTHER,fallback(...)): those items are just read from the environment. A variable that is set but empty does not fall back to the default. - Not repeated: varlock's validations beyond the type (
urlformat,min/max, string length and regex, non-empty arrays, ...) and its value transforms (isIntrounding,precision,toUpperCase,coerceToMinMaxRange). Values that would fail those checks under varlock load here. - Stricter than varlock on numbers: varlock parses with JavaScript's
parseFloat, which ignores trailing text, so it reads12abcas12,.5as0.5and5.as5, and rounds1.6to2for an integer type. Here those are errors. Items inside a JSON array or object must already have their JSON type ([1, 2], not["1", "2"]), where varlock coerces each one.
Compared with Dotenvy
Apps moving from Dotenvy env!/2,3 to this loader get these conversions (Dotenvy 1.1.0's Dotenvy.Transformer.to!/2):
| Dotenvy type | Dotenvy 1.1.0 | schema type | load_from_system_env/0 |
| --- | --- | --- | --- |
| any, variable unset | env!/2 raises; env!/3 returns its default | required / optional item | required: error, unless the schema has a literal default; optional: nil |
| :string | "" → "" | optional string | "" → "" (same) |
| :string? | "" → nil | optional string | "" → "" (differs) |
| :string! | "" raises | required string | "" → error (same) |
| :integer | Integer.parse/1, keeping the leading integer: "12abc" → 12, "1.9" → 1, " 1" raises; "" → 0 | optional number(isInt=true) or port | whole value must be an integer: " 1 " → 1; "12abc", "1.9" → error (stricter); "" → nil (differs) |
| :integer? | as :integer, "" → nil | optional int | as above, "" → nil (same) |
| :integer! | as :integer, "" raises | required int | as above, "" → error (same) |
| :float | Float.parse/1, keeping the leading float: "3" → 3.0, "1.5x" → 1.5, ".5" raises; "" → 0 | optional number | "3" → 3.0 (same); "1.5x" → error (stricter); ".5" → error (same); "" → nil (differs) |
| :float! | as :float, "" raises | required number | as above, "" → error (same) |
| :boolean | "false", "0", "" (any case) → false; every other string → true | optional boolean | true t yes on 1 → true, false f no off 0 → false (any case); anything else → error. So "no", "off", "f" are false here and true in Dotenvy, and "maybe" is an error here and true there (differs); "" → nil (differs) |
| :boolean? | as :boolean, "" → nil | optional boolean | as above, "" → nil (same) |
| :boolean! | as :boolean, "" raises | required boolean | as above, "" → error (same) |
Dotenvy's error messages include the variable name and type; these name the variable and the expected type, and neither includes the value.
Redaction
The generated module masks the values of @sensitive items wherever they turn up:
redact(env, term)replaces every occurrence of a sensitive value intermwith[REDACTED]. It walks strings, charlists, lists (improper lists too), tuples and maps, structs included (fields are redacted and the struct type kept). Map keys and other terms are left alone. Use it on error reports, for example in Sentry'sbefore_send:config :sentry, before_send: fn event -> MyApp.Env.redact(env, event) endinstall_log_filter!(env)addslog_filter/2as a primary:loggerfilter, so every event's message ({:string, chardata},{:report, report}or{format, args}) and metadata are redacted before any handler sees them. A{format, args}message is formatted into a string first, since the format string can hold a value too. Call it once at startup, in your application'sstart/2, say. It compiles the sensitive values into a match pattern once, and raises if a filter with the same id (:redact_env, or the second argument) is already installed. If redaction raises, the event is dropped (:stop), because:loggerremoves a filter that crashes and later events would go out unredacted.MyApp.Env.install_log_filter!(env)log_filter(event, env)also takes the env itself as its config, compiling the pattern on every event.sensitive_values(env)returns the strings it masks: every non-empty string in a sensitive field, including strings inside list and map values (as varlock collects them), longest first so an overlapping longer value is masked whole.
Values shorter than 3 characters are not masked, so a value like "a" can't rewrite ordinary text. That matches varlock, which reports a sensitive value under 3 characters as too short to redact safely (and warns under 12). Like varlock, only strings are masked: a sensitive integer or boolean is not. Keys left out of the struct (dotted.key) can't be redacted, since the struct doesn't hold them. Unlike varlock's own redaction, which keeps the first two characters (ab▒▒▒▒▒), the mask is fixed and reveals nothing. The installed filter keeps only the compiled pattern in the logger config, which inspect/1 shows as an opaque reference.
Development
Tools are pinned in mise.toml: Elixir 1.20.4 on OTP 29, and Node 26.
mise install
npm install
npm run build # dist/plugin.cjs
npm test # vitest: unit, generated-Elixir and varlock CLI suites (Elixir from mise.toml)
npm run test:elixir-1.19 # same suites against Elixir 1.19.6 / OTP 27
npm run test:all # both
npm run example:codegenCI (.github/workflows/ci.yml) runs the build, typecheck and all suites on Elixir 1.20.4 / OTP 29 and 1.19.6 / OTP 27, and generates the example module with the standalone varlock binary of the same version as the npm devDependency.
The tests have three layers:
test/generator.test.tscovers the emitter's output.test/elixir.test.tsgenerates a kitchen-sink module covering every type, reserved-word field names, injection attempts in docs and enum values, and long keys. It checks that module withmix format --check-formattedandmix test --warnings-as-errorsagainst crafted blobs and env vars, and checks a module of long, realistic keys againstline_length: 80.test/plugin.test.tsruns the realvarlockCLI against theexample/Mix project. It checks that the committed generated file is up to date, that real values load undervarlock run, and that the type checker catches misuse.
Known limitations
- Keys in list or map fields longer than the suite covers (54 characters at the default line length, 43 at
line_length: 80), and very longkeyType=enum(...)members, can produce a line the emitter doesn't wrap the waymix formatdoes, somix formatwould re-wrap it. The code still compiles. - Tested with the npm
varlockpackage and the standalone binary (1.23.0). The binary loads the plugin from a file path (absolute or relative to the schema) and generates the same module.
