Cooper Tutorial

Copy Markdown

This tutorial builds up a small "orders service" config file, feature by feature, until it exercises every part of CASC that Cooper implements. By the end you'll know how to load config, handle secrets and errors, inject test-time environment/import values, guard statements on an env var, layer .env files, and use Cooper's own caching and change-notification support — and you'll have seen enough CASC syntax to write and read real config files. For the full language reference, see CASC.md; this tutorial only introduces as much of it as the example needs.

1. The minimal file

Every CASC file needs a version header — a comment-shaped line that also declares which version of the language the file is written against:

#@version = 1.0

That alone is a complete, valid file:

iex> Cooper.load_string("#@version = 1.0")
{:ok, %{}}

Cooper.load_string/2 parses a CASC source string directly. Cooper.load_file/2 reads a file from disk first — use it for real config files; load_string/2 is convenient for tests and this tutorial. Both return {:ok, term()} or {:error, reason}; see §6 for what reason looks like.

2. Plain values and blocks

Assignments look like ordinary key/value pairs. Nested blocks build dotted paths automatically:

#@version = 1.0

app_name = "orders"
replicas = 3

server {
  host = "0.0.0.0"
  port = 8080
}
iex> Cooper.load_string(source)
{:ok,
 %{
   "app_name" => "orders",
   "replicas" => 3,
   "server" => %{"host" => "0.0.0.0", "port" => 8080}
 }}

server { host = ... } and server.host = ... are equivalent — pick whichever reads better for a given block. Values include the usual scalars (strings, integers, floats, booleans, nil, atoms), plus a few CASC-specific literals: dates/times, IP addresses/CIDRs, durations (500ms, 1h30m), and byte sizes (512MiB). Durations and byte sizes come back as tagged tuples — {:duration, nanoseconds} and {:bytes, count} — not bare integers, so they're never confused with an ordinary number written in the same config. See CASC.md §6 for the full literal grammar.

IP addresses are a real data type, not just parsed text — 127.0.0.1 resolves to %Cooper.IPv4{}, ::1 to %Cooper.IPv6{}, both validated at load time (an out-of-range octet or a CIDR prefix outside 0..32/ 0..128 is a load-time error, never a crash). Both support CIDR containment and network math directly:

#@version = 1.0

allowed_networks = [10.0.0.0/8, 192.168.1.0/24]
iex> {:ok, config} = Cooper.load_string(source)
iex> [office, vpn] = config["allowed_networks"]
[#Cooper.IPv4<10.0.0.0/8>, #Cooper.IPv4<192.168.1.0/24>]
iex> {:ok, candidate} = Cooper.IPv4.new({10, 1, 2, 3})
iex> Cooper.IPv4.contains?(office, candidate)
true

See the cheatsheet for the full Cooper.IPv4/ Cooper.IPv6 API (network/1, broadcast/1, first_host/1, last_host/1, netmask/1).

3. Variables and interpolation

@name = value declares a variable. It isn't itself part of the output tree — it's only useful for reference elsewhere via @{name}:

#@version = 1.0

@region = "eu-west"

endpoint = "https://api.@{region}.example.com"
iex> Cooper.load_string(source)
{:ok, %{"endpoint" => "https://api.eu-west.example.com"}}

@{name} also works as a bare, whole value (region = @{other_var}), not just embedded in a string. @*name = value declares a private variable — usable within the file (and files it imports, see §7), but never inherited by whatever imports it back.

4. Environment references

${NAME} reads an OS (or injected, see §7) environment variable — including one layered in from a .env file, on by default; see §11. Unlike @{...}, it always resolves to a plain string — CASC never guesses that a string "looks like" a number or boolean for you:

#@version = 1.0

region = ${REGION}
max_retries = !int(${MAX_RETRIES:3})
${?FEATURE_FLAG}
enabled = true
allowed_hosts = ${ALLOWED_HOSTS[]:["localhost"]}

With REGION=eu-west set and everything else unset:

iex> Cooper.load_string(source, env: %{"REGION" => "eu-west"})
{:ok,
 %{"allowed_hosts" => ["localhost"], "max_retries" => 3, "region" => "eu-west"}}

A few things happened there:

  • ${NAME:default} falls back to default — parsed as an ordinary CASC value, so a bare 3 really is the integer 3, not "3".
  • !int(...) is a tagged value: it wraps whatever's inside (typically a ${...} read, since that's always a string) and converts it. !int, !float, !bool, !duration, and !bytes are built in; register more via the :tags option (CASC.md §9.1; see §10 for :resolvers, the analogous option for !{resolver:payload}).
  • ${?FEATURE_FLAG} guards the one statement that follows it — with FEATURE_FLAG unset, enabled = true is skipped entirely (not evaluated-then-discarded — nothing inside a skipped statement can raise just because it happened to be skipped). See §13 for the full treatment.
  • ${NAME[]:default} parses a set/unset env var as a comma-separated list.

5. Secrets

Prefixing any key segment with * marks it (and everything under it) secret. Secret values load normally, but come back wrapped in Cooper.Secret, which redacts itself on inspection:

#@version = 1.0

database {
  *password = ${DB_PASSWORD}
  host = "db.internal"
}
iex> {:ok, config} = Cooper.load_string(source, env: %{"DB_PASSWORD" => "hunter2"})
iex> config
%{"database" => %{"host" => "db.internal", "password" => [~~REDACTED~~]}}
iex> IO.inspect(config)
%{"database" => %{"host" => "db.internal", "password" => [~~REDACTED~~]}}
iex> "connecting with #{config["database"]["password"]}"
"connecting with [~~REDACTED~~]"
iex> Cooper.Secret.reveal(config["database"]["password"])
"hunter2"

Both inspect/1 and to_string/1 redact unconditionally — there's no "debug mode" flag to accidentally leave on in production. The only way to get the real value is Cooper.Secret.reveal/1 (or the struct's own :value field), which makes "did I accidentally log a secret" a type-level question you can grep for, not a runtime toggle you have to trust everyone remembered to check.

The wrapping travels with the value, not just the key it was declared at — a %{...} reference or a for ... from template that copies a secret value somewhere else copies the wrapping right along with it, so there's no path in the tree where the same underlying secret shows up unprotected. And a secret embedded inside a larger interpolated string only redacts its own portion, not the whole string — including when more than one secret is interpolated into the same string, each redacts independently at its own position:

url = "postgres://%{database.host}?password=%{database.password}"
iex> config["url"]
"postgres://db.internal?password=[~~REDACTED~~]"
iex> Cooper.Secret.reveal(config["url"])
"postgres://db.internal?password=hunter2"

6. Errors

Every stage of loading can fail, and every failure comes back as {:error, %Ichor.Error{}} (or a list of them, for the parser's own multi-error recovery) — never a raised exception. Ichor.Error carries a :stage telling you roughly where things went wrong (:lexer, :parser, :import, :merge, :resolve, ...) alongside a human-readable :message:

iex> Cooper.load_string("#@version = 1.0\nvalue = !{missing:x}")
{:error,
 %Ichor.Error{message: "unregistered resolver \"missing\"", stage: :resolve, ...}}

Unregistered resolvers, unregistered tags, unregistered import schemes, a %{...} reference cycle, and a mismatched-length loop binding are all load-time errors naming the offender specifically — Cooper never silently drops a reference or guesses at intent.

7. Imports and test-time injection

import "path/to/file.casc" splices another file's own statements in at that point — later statements (including the importer's own, following the import) override earlier ones at the same path, and any public @name variable the imported file declares becomes visible to the importer too:

#@version = 1.0

import "defaults.casc"

server.port = 9090

For a bare path, Cooper.load_file/2 resolves relative imports against the loaded file's own directory automatically; load_string/2 needs an explicit :root for that (or no imports at all).

You can also register your own scheme:// import loader and resolver functions — this is what makes it practical to unit-test config that imports from, say, a secrets manager, without touching the filesystem or a real network call:

iex> source = """
...> #@version = 1.0
...> import "mem://extra"
...> app_name = "orders"
...> """
iex> schemes = %{"mem" => fn _rest -> {:ok, "#@version = 1.0\\nregion = \\"eu-west\\""} end}
iex> Cooper.load_string(source, import_schemes: schemes)
{:ok, %{"app_name" => "orders", "region" => "eu-west"}}

:resolvers/:tags work the same way — in-memory stand-ins for whatever a real resolver would call out to, with no global state to set up or tear down.

:env (every example in this tutorial already uses it) is a little different: it's an override, not a replacement — see §11 for why env: %{"REGION" => "eu-west"} guarantees REGION specifically but doesn't isolate a test from the real environment or any .env file on disk the way :resolvers/import_schemes do. Give every name a test needs a deterministic value for its own explicit :env entry, rather than relying on it being otherwise unset.

8. Merge control and loops

Later statements override earlier ones at the same path, and maps deep-merge by default. A leading sigil on a key changes that:

#@version = 1.0

server { host = "0.0.0.0", port = 8080, tls { min_version = "1.2" } }
~server { port = 9090 }

tags = ["a", "b", "c"]
+tags = ["d"]
-tags = ["b"]
iex> Cooper.load_string(source)
{:ok, %{"server" => %{"port" => 9090}, "tags" => ["a", "c", "d"]}}

~key { ... } replaces the entire subtree at key, not just the fields the new block mentions — host and tls are gone, not merged. +/- append/remove list elements by value. Tuples (CASC.md §6.11) are never merged at all — only replaced wholesale — since Cooper has no schema to know which position means what.

for loops generate many entries from one template:

#@version = 1.0

defaults.replica {
  cpu = 1
  memory_mb = 512
}

@instances = ["a", "b", "c"]
for @instance in @{instances} from defaults.replica as replicas."@{instance}" {
  cpu = 2
}
iex> Cooper.load_string(source)
{:ok,
 %{
   "defaults" => %{"replica" => %{"cpu" => 1, "memory_mb" => 512}},
   "replicas" => %{
     "a" => %{"cpu" => 2, "memory_mb" => 512},
     "b" => %{"cpu" => 2, "memory_mb" => 512},
     "c" => %{"cpu" => 2, "memory_mb" => 512}
   }
 }}

Each generated replicas.<instance> starts as a copy of defaults.replica (from, resolved lazily against the final merged tree — it doesn't matter whether defaults.replica is written before or after the loop), with the loop body layered on top as overrides. See CASC.md §5.5 for parallel (zipped) multi-binding loops and index bindings.

9. Config references

%{path} refers to another value in the same, fully-merged tree — resolved lazily, after every import and merge, so it always sees the final value regardless of where in the file (or which imported file) the reference itself was written:

#@version = 1.0

server.host = "api.internal"
server.port = 8080
health_check.url = "http://%{server.host}:%{server.port}/health"
admin_email = %{contact.admin:"ops@example.com"}
iex> Cooper.load_string(source)
{:ok,
 %{
   "admin_email" => "ops@example.com",
   "health_check" => %{"url" => "http://api.internal:8080/health"},
   "server" => %{"host" => "api.internal", "port" => 8080}
 }}

A %{...} cycle (directly or transitively referencing itself) is a load-time error naming the full cycle path — never an infinite loop or a silently partial value.

10. Putting it together

Everything above composes into one file and one call:

#@version = 1.0

@region = ${REGION:"eu-west"}

server {
  host = "0.0.0.0"
  port = !int(${PORT:8080})
}

database {
  *password = !{vault:secret/db/password}
  host = "db.@{region}.internal"
  timeout = 500ms
}

endpoints.health = "http://%{server.host}:%{server.port}/health"
resolvers = %{"vault" => fn payload -> MyVault.fetch(payload) end}

case Cooper.load_file("config.casc", resolvers: resolvers) do
  {:ok, config} ->
    config

  {:error, %Ichor.Error{} = error} ->
    raise "invalid config: #{error.message}"
end

11. .env files

By default, Cooper layers .env files from the project root into ${...} resolution — no extra option needed. The full chain, later winning:

iex> Cooper.load_file("config.casc")
  1. System.get_env/0 — always the floor, whether or not :env is passed
  2. .env
  3. .env.<env><env>, in order: an explicit :dotenv_env; else live Mix.env/0 when Mix is loaded (mix run/mix test/iex -S mix); else Application.compile_env(:cooper, :dotenv_env), if a compiled release's own config/config.exs set config :cooper, dotenv_env: config_env() — the only way left to auto-detect an environment once Mix itself isn't around. Pass dotenv_env: explicitly instead if you'd rather not add that config
  4. .env.local — a personal, usually-gitignored override
  5. :env, if passed — always the final, highest-precedence override

A missing file (2-4) is never an error — .env.<env> in particular is expected to be absent for every environment but the current one.

:env overrides, it doesn't isolate. env: %{"REGION" => "eu-west"} guarantees REGION resolves to "eu-west" — but any ${...} reference to a name not in that map still falls through to .env/.env.local/the real OS environment, same as if :env weren't passed at all:

iex> Cooper.load_string(source, env: %{"REGION" => "eu-west"})
# REGION is guaranteed -- everything else still reads through to
# .env/.env.local/the real environment

dotenv: false disables just the .env file layers (2-4) — System.get_env/0 and :env still apply either way. :dotenv_files fully replaces the default four-file list, for a non-standard layout. This runs through the optional :dotenvy dependency — add {:dotenvy, "~> 1.1"} to your own mix.exs deps. An explicit dotenv: true without it installed is a load-time error naming it; the default-enabled case just no-ops instead (same as no .env files existing).

12. Caching

Cooper.load_file/2 caches everything up to the final ${...}-resolution step by default, reusing it across calls as long as every file that contributed to it -- the entry file, plus every transitively bare-imported file -- still has the mtime it had when the cache entry was populated:

iex> Cooper.load_file("config.casc")

${...} resolution itself is never cached — it re-runs against a freshly-computed :env (.env files included) on every call, hit or miss, so an ordinary ${NAME} value is always current regardless of whether the surrounding tree came from the cache:

iex> Cooper.load_file("config.casc")
{:ok, %{"port" => 8080}}
iex> System.put_env("PORT", "9090")
iex> Cooper.load_file("config.casc")
{:ok, %{"port" => 9090}}  # picked up immediately, no file change needed

Pass cache: false to bypass the cache entirely for one call — a full reparse every time, for forcing a guaranteed-fresh read, or in tests. Cooper.Cache.invalidate/1 and Cooper.Cache.clear/0 bust a specific entry or everything, for anywhere you need to force a reload without waiting for a file-change check.

One real limitation, not a bug: a ${?NAME} guard's decision is baked in at the moment the cache entry is populated — it's resolved during parsing, long before the always-fresh ${...}-resolution step runs — so it only refreshes when the entry itself invalidates (a fingerprinted file changes, or an explicit invalidate/1/clear/0), not on every access the way an ordinary value read is. See Cooper.Cache's own moduledoc for the full reasoning, and §13 below for guards specifically. (A ${...} inside an import "..." path is a different matter — CASC.md §5.1 doesn't support it at all, so it's always an unconditional load-time error, never something that gets cached and goes stale.)

Getting notified of changes

load_file/2 emits :telemetry events — attach a handler the ordinary way:

:telemetry.attach_many(
  "my-app-config-watcher",
  [[:cooper, :cache, :file_changed], [:cooper, :cache, :env_changed]],
  fn event, _measurements, metadata, _config ->
    Logger.info("config changed: #{inspect(event)} #{inspect(metadata)}")
  end,
  nil
)

[:cooper, :cache, :file_changed] fires whenever a previously cached entry's fingerprint no longer matches disk — never on the first load of a file, since nothing "changed" yet at that point. Metadata: %{path: String.t(), root: String.t(), changed_files: [String.t()]}.

[:cooper, :cache, :env_changed] defaults to on for any call that references ${...} at all (an ordinary value, a guard, or both) — off for one that doesn't; pass watch_env: true/false to override either way. Only the ${NAME}s the call actually reads are watched — not the whole environment — against Cooper.Dotenv.env/1, so a real System.put_env/2 and a .env file edit both count as the same kind of change, with no separate file-watching needed for the latter. There's no OS-level notification for either, so this is a poll on a timer (Application.get_env(:cooper, :env_poll_interval, 5_000)), not pushed live:

iex> Cooper.load_file("config.casc")
{:ok, %{"port" => 8080}}
# ... later, from anywhere: System.put_env("PORT", "9090")
# within one poll interval:
# [:cooper, :cache, :env_changed] fires with changed_names: ["PORT"]

Metadata: %{path: String.t(), root: String.t(), changed_names: [String.t()]}. A real OS-level environment change made outside this running app (a different shell, a systemd unit file, /etc/environment) is never observable at all, by Cooper or anything else — once a BEAM process boots, its own environment is a fixed snapshot; only System.put_env/2 calls made from inside that same running app (or a .env file the poll re-reads) can ever change what it sees.

13. Guards

${?NAME} (CASC.md §7.2) guards the one statement immediately after it — write them on the same line:

#@version = 1.0

${?FEATURE_FLAG} beta_enabled = true
log_level = info

With FEATURE_FLAG unset (or set to an empty string — CASC treats those the same), beta_enabled is skipped entirely and doesn't appear in the result at all:

iex> Cooper.load_string(source, env: %{})
{:ok, %{"log_level" => :info}}
iex> Cooper.load_string(source, env: %{"FEATURE_FLAG" => "1"})
{:ok, %{"beta_enabled" => true, "log_level" => :info}}

A guarded statement is never evaluated-then-discarded the way a #-disabled one is — nothing inside it (an unregistered tag, an undefined variable, whatever) can raise just because it happened to be skipped, since it's never touched at all when the guard doesn't pass. Only the statement directly after the guard is covered — an ordinary statement two lines down is unaffected, guard or not:

#@version = 1.0

${?MAYBE} guarded = true
kept = 1
iex> Cooper.load_string(source, env: %{})
{:ok, %{"kept" => 1}}

A guard can wrap any statement — a block, a for loop, an import — not just a plain assignment:

#@version = 1.0

${?ENABLE_METRICS} metrics { port = 9090, path = "/metrics" }

Guards and caching: Cooper.load_file/2 caches the parsed tree by default (§12), and a guard's decision is part of what gets cached — it doesn't re-evaluate on every call the way an ordinary ${...} value does. watch_env defaults to on for any file that references the environment at all, guards included, so FEATURE_FLAG flipping is still detected (and the cache entry invalidated) within one poll interval — see §12's "Getting notified of changes" for the full story.

From here, CASC.md is the full reference for anything this tutorial only touched briefly, and the cheatsheet is a fast lookup for Cooper's own public API.