The tutorial builds one example — an orders service config — up from nothing. This page walks through several more, each focused on one thing you're likely to actually need Cooper for. For examples focused on CASC syntax rather than the surrounding Elixir, see CASC_EXAMPLES.md.

Application config with a real secrets manager

The common shape: config loaded once at boot, with secrets fetched from wherever your organization actually keeps them, never landing in source control or a log line.

defmodule MyApp.Config do
  def load! do
    resolvers = %{"vault" => &fetch_from_vault/1}

    case Cooper.load_file("config/app.casc", resolvers: resolvers) do
      {:ok, config} -> config
      {:error, %Ichor.Error{} = error} -> raise "invalid config: #{error.message}"
    end
  end

  defp fetch_from_vault(path) do
    case MyVaultClient.read(path) do
      {:ok, %{"value" => value}} -> {:ok, value}
      {:error, reason} -> {:error, "vault read failed: #{inspect(reason)}"}
    end
  end
end
#@version = 1.0

database {
  *password = !{vault:secret/data/orders-db#password}
  host = "db.internal"
  pool_size = !int(${DB_POOL_SIZE:10})
}

config["database"]["password"] comes back wrapped in Cooper.Secret — safe to hold in application state and pass around; Cooper.Secret.reveal/1 is the one deliberate step needed to use the real value (e.g. building an Ecto.Repo connection string).

Layering base config with per-environment overlays

# config/base.casc
#@version = 1.0

server { host = "0.0.0.0", port = 8080 }
log_level = info
feature_flags.new_checkout = false
# config/prod.casc
#@version = 1.0

import "base.casc"

~server { port = 443 }
log_level = warning
feature_flags.new_checkout = true
iex> Cooper.load_file("config/prod.casc")
{:ok,
 %{
   "server" => %{"port" => 443},
   "log_level" => :warning,
   "feature_flags" => %{"new_checkout" => true}
 }}

import splices base.casc's statements in first; every statement in prod.casc after it — including ~server { port = 443 }, which replaces the whole block rather than merging — overrides the base at the same path. Adding config/staging.casc alongside prod.casc, each importing base.casc and overriding just what differs, avoids duplicating the shared two-thirds of the file across every environment.

Testing config-loading code without touching disk or the network

Every option that would otherwise reach for the real filesystem, OS environment, or an external service is a plain function or map — which means a config loader can be unit-tested the same way as any other pure function, no fixtures directory or mocking library required:

defmodule MyApp.ConfigTest do
  use ExUnit.Case, async: true

  test "prod overlay raises the connection pool and disables debug routes" do
    source = """
    #@version = 1.0

    import "mem://base"

    pool_size = !int(${POOL_SIZE:20})
    """

    schemes = %{
      "mem" => fn "base" ->
        {:ok, "#@version = 1.0\npool_size = 5\ndebug_routes = true\n"}
      end
    }

    assert {:ok, config} =
             Cooper.load_string(source,
               import_schemes: schemes,
               env: %{"POOL_SIZE" => "50"}
             )

    assert config == %{"pool_size" => 50, "debug_routes" => true}
  end
end

No real file named base.casc exists anywhere — mem://base is resolved entirely in memory by the test's own schemes map, and POOL_SIZE is a plain map, not a real environment variable that could leak between test runs.

IP allowlisting with real CIDR math

#@version = 1.0

allowed_networks = [10.0.0.0/8, 192.168.1.0/24, 203.0.113.7/32]
defmodule MyApp.AccessControl do
  def allowed?(config, %Cooper.IPv4{} = candidate) do
    Enum.any?(config["allowed_networks"], &Cooper.IPv4.contains?(&1, candidate))
  end
end
iex> {:ok, config} = Cooper.load_string(source)
iex> {:ok, request_ip} = Cooper.IPv4.new({10, 1, 2, 3})
iex> MyApp.AccessControl.allowed?(config, request_ip)
true

Every address in allowed_networks is a real %Cooper.IPv4{}, validated at load time (a malformed address or an out-of-range CIDR prefix is a load-time error, never a crash reachable by a bad config file) — contains?/2 does genuine bitwise network-membership math, not a string prefix comparison.

Generating per-shard config from one template

#@version = 1.0

defaults.shard {
  replicas = 1
  memory_limit = 512MiB
}

@shard_names = ["orders", "inventory", "billing"]
@shard_replicas = [3, 2, 1]

for @name in @{shard_names}, @replicas in @{shard_replicas}
  from defaults.shard as shards."@{name}" {
  replicas = @{replicas}
}
iex> Cooper.load_string(source)
{:ok,
 %{
   "defaults" => %{"shard" => %{"replicas" => 1, "memory_limit" => {:bytes, 536_870_912}}},
   "shards" => %{
     "orders" => %{"replicas" => 3, "memory_limit" => {:bytes, 536_870_912}},
     "inventory" => %{"replicas" => 2, "memory_limit" => {:bytes, 536_870_912}},
     "billing" => %{"replicas" => 1, "memory_limit" => {:bytes, 536_870_912}}
   }
 }}

Each of the three shards starts as a full copy of defaults.shard (memory_limit carries through unchanged to every one), with only replicas overridden per shard from the parallel @shard_replicas list — adding a fourth shard is one more entry in each of the two lists, not a fourth copy-pasted block.

A consumer-defined tagged value

Built-in tags (!int, !float, !bool, !duration, !bytes) cover type coercion; :tags extends the same !Name(arg) syntax to your own vocabulary — a real UUID validator here, not just a string pass-through:

#@version = 1.0

request_id = !uuid("550e8400-e29b-41d4-a716-446655440000")
tags = %{
  "uuid" => fn arg ->
    case UUID.info(arg) do
      {:ok, _} -> {:ok, arg}
      {:error, reason} -> {:error, "invalid UUID: #{reason}"}
    end
  end
}

iex> Cooper.load_string(source, tags: tags)
{:ok, %{"request_id" => "550e8400-e29b-41d4-a716-446655440000"}}

A config file using !uuid(...) without :tags registered fails to load with a load-time error naming "uuid" — never silently treated as an opaque string.

Turning a load failure into an actionable message

Every stage returns {:error, %Ichor.Error{}} rather than raising, so a caller decides what "actionable" means for its own context — here, a mix task exiting with a message pointing at the exact file and line, instead of an Elixir stack trace:

defmodule Mix.Tasks.Config.Validate do
  use Mix.Task

  def run([path]) do
    case Cooper.load_file(path) do
      {:ok, _config} ->
        Mix.shell().info("#{path}: OK")

      {:error, errors} ->
        errors
        |> List.wrap()
        |> Enum.each(fn %Ichor.Error{stage: stage, message: message, line: line} ->
          location = if line, do: "#{path}:#{line}", else: path
          Mix.shell().error("#{location} [#{stage}]: #{message}")
        end)

        Mix.raise("config validation failed")
    end
  end
end

{:error, ...} may be a single Ichor.Error or a list of them (the parser's own multi-error recovery for a syntax error) — List.wrap/1 handles both uniformly rather than branching on shape.