npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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_keys for required items, and a full @type t
  • loads the __VARLOCK_ENV JSON blob that varlock run injects, through load/0, load!/0 (which raises MyApp.Env.LoadError) or from_blob/1 (useful in tests)
  • loads plain env vars without varlock, through load_from_system_env/0 and load_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 @sensitive items from inspect/1 with @derive {Inspect, except: ...}, redacts their values from any term with redact/2 and from every log event once install_log_filter!/2 adds log_filter/2 to :logger (see Redaction), and exposes sensitive_keys/0 and sensitive_values/1 for your own leak scanning
  • maps each struct field back to its env key with env_keys/0 (%{database_url: "DATABASE_URL", ...}), so tooling that sees env.database_url in your code (a usage audit, say) can tell which variable it reads. varlock's own varlock audit doesn't read this map; it's there for scripts and future tooling
  • includes each item's description, @docs links, 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 with Map.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"
end
warning: 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/1 on 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) without keyType=enum(...), simple-object): values are validated at load time and declared in @type t, but Elixir doesn't track value types through for ... into: %{}, Map.new or Map.filter, so the compiler sees them as dynamic().
  • Possibly-nil optional fields: the compiler only warns when a type can never fit, and binary() or nil overlaps with binary(). That's Elixir's gradual-typing design, and it should change once Elixir gets explicit type signatures.
  • Enum functions don't pass item types through, because Enum works 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

  1. Build the plugin (npm run build → dist/plugin.cjs) and copy that single file into your project, or install @seateddotcom/varlock-elixir-plugin from npm and use @plugin(@seateddotcom/varlock-elixir-plugin). With the standalone varlock binary, npm plugins need an exact version: @plugin(@seateddotcom/[email protected]).

  2. Add the decorators to .env.schema and run varlock codegen once. Commit the generated file, or add it to .gitignore and let varlock run regenerate it.

  3. Run your app under varlock so the blob is injected: varlock run -- mix phx.server, varlock run -- iex -S mix, and so on.

  4. Read config where you need it. config/runtime.exs is a good place (see example/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
    end

    The guard lets plain mix test run without varlock. Tests can build envs with from_blob/1 or 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 is nil. An empty optional item is nil, 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 literal forEnv(...) arguments from the env graph. Once every item is read, load_from_system_env/0 checks them against the env flag's value, or its literal in .env.schema when it is unset, and fails with DB_URL is required in staging but not set in the environment. The struct type keeps these items as T | nil. Requiredness from anything else, such as @required=$FLAG or not(...), is left to varlock.
  • Defaults. An unset item falls back to its value in .env.schema when 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 a schema-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 (url format, min/max, string length and regex, non-empty arrays, ...) and its value transforms (isInt rounding, 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 reads 12abc as 12, .5 as 0.5 and 5. as 5, and rounds 1.6 to 2 for 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 in term with [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's before_send:

    config :sentry, before_send: fn event -> MyApp.Env.redact(env, event) end
  • install_log_filter!(env) adds log_filter/2 as a primary :logger filter, 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's start/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 :logger removes 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:codegen

CI (.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.ts covers the emitter's output.
  • test/elixir.test.ts generates 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 with mix format --check-formatted and mix test --warnings-as-errors against crafted blobs and env vars, and checks a module of long, realistic keys against line_length: 80.
  • test/plugin.test.ts runs the real varlock CLI against the example/ Mix project. It checks that the committed generated file is up to date, that real values load under varlock 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 long keyType=enum(...) members, can produce a line the emitter doesn't wrap the way mix format does, so mix format would re-wrap it. The code still compiles.
  • Tested with the npm varlock package 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.