A collection of custom Credo checks for Elixir projects, designed to catch common anti-patterns and guide both human developers and AI coding agents toward idiomatic, maintainable Elixir code.
Note: This library was originally created to assist AI agents (such as Copilot, Claude, and similar tools) in avoiding common Elixir anti-patterns that they tend to reproduce. The checks are equally useful for human developers, but the primary motivation was to give AI-assisted codebases a static analysis layer that pushes back on known-bad patterns before they land in review.
Anti-patterns targeted by this library are drawn primarily from:
Note
Checks are here to assist, not to legislate. Some rules in this library are genuinely controversial within the Elixir community — reasonable people disagree. The goal is to nudge toward good defaults, not to enforce a single style religion. If a check doesn't fit your project's conventions, disable it. See Disabling Checks.
Installation
Add lex_credo to your dependencies in mix.exs. It is typically only needed in :dev and :test:
def deps do
[
{:lex_credo, "~> 0.1.0", only: [:dev, :test], runtime: false}
]
endThen run:
mix deps.get
Usage
1. Add checks to your .credo.exs
In the checks: %{enabled: [...]} section of your .credo.exs, add any checks you want to enable:
checks: %{
enabled: [
# ... your existing checks ...
{LexCredo.Check.Design.NoNestedModules, []},
{LexCredo.Check.Readability.DocExamplesSection, []},
{LexCredo.Check.Refactor.NoEnumWrapperFunctions, []},
{LexCredo.Check.Warning.NoComplexWithElse, []},
{LexCredo.Check.Warning.NoEnumAllAssert, []},
{LexCredo.Check.Warning.NoPipeIntoCase, []},
{LexCredo.Check.Warning.NoProcessSleepInTests, []},
{LexCredo.Check.Warning.NoTaggedWithClauses, []},
{LexCredo.Check.Warning.PreferBooleanOperators, []},
{LexCredo.Check.Warning.UsePositiveTypeGuards, []},
{LexCredo.Check.Warning.UseStartSupervised, []},
]
}2. Run Credo
mix credo
# or for strict mode (includes low-priority checks):
mix credo --strict
General Parameters
All checks accept the following parameters.
exclude_test_files
Skip test files for this check. Default is true for NoNestedModules (which
skips test files by default to allow inline helper modules) and false for all
other checks.
# Skip test files for a check that normally runs everywhere
{LexCredo.Check.Readability.DocExamplesSection, [exclude_test_files: true]}
# Run NoNestedModules in test files too
{LexCredo.Check.Design.NoNestedModules, [exclude_test_files: false]}Standard Credo parameters
false— disable the check entirely:{LexCredo.Check.Warning.NoPipeIntoCase, false}exit_status— make a check advisory-only (reports issues but does not affect the exit code):{LexCredo.Check.Warning.PreferBooleanOperators, [exit_status: 0]}priority— override the check's base priority.
Suppressing individual warnings
Use a Credo inline comment to suppress a single occurrence without disabling the check globally:
result |> case do # credo:disable-for-next-line LexCredo.Check.Warning.NoPipeIntoCaseIncluded Checks
Design
LexCredo.Check.Design.NoNestedModules
Category: Design | Priority: High
Flags defmodule blocks nested inside another defmodule. Nested modules obscure the module hierarchy and make code harder to navigate. Define each module in its own file instead.
# flagged
defmodule Outer do
defmodule Inner do # <-- flagged
...
end
end
# preferred
defmodule Outer do ... end
defmodule Outer.Inner do ... endSkips test files, where anonymous and helper modules inline are common.
Readability
LexCredo.Check.Readability.DocExamplesSection
Category: Readability | Priority: Normal
Controversial
Requiring an ## Examples section in every public function doc is a strong convention that not all teams share. Some functions are genuinely self-documenting. Consider disabling this check if your team finds it too prescriptive.
Flags @doc strings on public functions that are missing an ## Examples section. Inline examples improve discoverability and double as living documentation when used with doctest.
# flagged — no Examples section
@doc """
Parses a date string.
"""
def parse_date(str), do: ...
# preferred
@doc """
Parses a date string.
## Examples
iex> MyApp.parse_date("2024-01-01")
{:ok, ~D[2024-01-01]}
"""
def parse_date(str), do: ...Does not fire on
@doc falseor@doc nil.
Refactor
LexCredo.Check.Refactor.NoEnumWrapperFunctions
Category: Refactor | Priority: Normal
Controversial
There are legitimate reasons to wrap an Enum call in a named function (naming intent, easier testing, future extensibility). This check flags only the cases where the wrapper adds no abstraction value at all. If your wrapper function carries a meaningful name that clarifies intent, disable this check or add it to an allow-list.
Flags named functions (def/defp) whose entire body is a single Enum or Stream transformation call (map, flat_map, each, map_reduce, flat_map_reduce, scan). These wrappers add indirection without adding meaning — callers can compose Enum directly.
# flagged
def user_names(users), do: Enum.map(users, & &1.name)
# preferred — inline at the call site
Enum.map(users, & &1.name)
# or, if transformation logic is complex, name the transform itself
def name_of(%User{name: name}), do: name
Enum.map(users, &name_of/1)Aggregation and predicate functions (
any?,all?,count,sum, etc.) are intentionally excluded from this check.
Warning
LexCredo.Check.Warning.NoComplexWithElse
Category: Warning | Priority: Normal | Configurable
Flags with expressions whose else block has more than max_else_clauses clauses (default: 1). Complex else blocks usually signal that error normalisation should happen in a helper or that a case expression is more appropriate.
# flagged — 2 else clauses, default max is 1
with {:ok, user} <- fetch_user(id),
{:ok, post} <- fetch_post(user) do
post
else
{:error, :not_found} -> {:error, :user_not_found}
{:error, :forbidden} -> {:error, :access_denied}
end
# preferred — each function returns a normalised error atom,
# so a single catch-all clause in else is sufficient
with {:ok, user} <- fetch_user(id),
{:ok, post} <- fetch_post(user) do
post
else
{:error, reason} -> {:error, reason}
endConfiguration:
{LexCredo.Check.Warning.NoComplexWithElse, [max_else_clauses: 2]}LexCredo.Check.Warning.NoEnumAllAssert
Category: Warning | Priority: Normal | Test files only
Flags assert Enum.all?(collection, predicate) in test files. When this assertion fails, you get no indication of which element failed. A for loop with individual assertions gives a specific failure message for each item.
# flagged
assert Enum.all?(users, &(&1.active))
# preferred
for user <- users do
assert user.active, "expected user #{user.id} to be active"
endLexCredo.Check.Warning.NoPipeIntoCase
Category: Warning | Priority: High
Controversial
The |> case do pattern is used widely in the Elixir community and is syntactically valid. Many developers find it natural and prefer it for readability in pipelines. This check reflects the preference from Keathley's style guide to bind the intermediate value first so it can be inspected more easily. Disable if your team is comfortable with |> case do.
Flags |> case do patterns. Binding the piped value to a named variable before branching makes the code easier to debug and the variable available for logging or pattern matching.
# flagged
result
|> transform()
|> case do
{:ok, val} -> val
{:error, _} -> nil
end
# preferred
transformed = result |> transform()
case transformed do
{:ok, val} -> val
{:error, _} -> nil
endLexCredo.Check.Warning.NoProcessSleepInTests
Category: Warning | Priority: High | Test files only
Flags Process.sleep/1 and Process.alive?/1 in test files. sleep makes test suites slow and flaky; alive? produces race conditions. Use Process.monitor/1 + assert_receive {:DOWN, ...} or :sys.get_state/1 for deterministic synchronisation.
# flagged
Process.sleep(100)
assert Process.alive?(pid)
# preferred
ref = Process.monitor(pid)
assert_receive {:DOWN, ^ref, :process, ^pid, _reason}LexCredo.Check.Warning.NoTaggedWithClauses
Category: Warning | Priority: High
Flags the tagged-tuple workaround used to identify which with clause failed in the else block:
# flagged — tags exist only to tell apart which step failed
with {:user, {:ok, user}} <- {:user, fetch_user(id)},
{:post, {:ok, post}} <- {:post, fetch_post(user)} do
{:ok, post}
else
{:user, {:error, reason}} -> {:error, {:user, reason}}
{:post, {:error, reason}} -> {:error, {:post, reason}}
end
# preferred — each function returns a distinct error atom,
# so the step that failed is clear without wrapping tuples
with {:ok, user} <- fetch_user(id),
{:ok, post} <- fetch_post(user) do
{:ok, post}
else
{:error, :user_not_found} = err -> err
{:error, :post_not_found} = err -> err
endThis pattern exists to work around with's inability to match partial results in else. Prefer having each function return a distinct, self-describing error tuple so no wrapping is needed.
LexCredo.Check.Warning.PreferBooleanOperators
Category: Warning | Priority: Normal
Controversial
The &&/||/! vs and/or/not distinction is one of the most debated style points in Elixir. Both sets of operators are valid in non-guard contexts. &&/|| are more familiar to developers coming from other languages. This check reflects the opinion from the official anti-patterns guide that and/or/not should be preferred when operands are boolean-typed, since it signals intent more clearly. Disable if your team prefers &&/|| uniformly.
Flags &&, ||, and ! when at least one operand is a clearly boolean-yielding expression (an is_* guard, a comparison, a boolean literal, or another boolean operator). In these cases, and, or, and not are preferred.
# flagged — operands are boolean-typed
is_binary(x) && is_integer(y)
has_permission?(user) || is_admin?(user)
!is_nil(value)
# preferred
is_binary(x) and is_integer(y)
has_permission?(user) or is_admin?(user)
not is_nil(value)
# not flagged — truthy/falsy short-circuit idiom, not boolean-typed
user && user.name
config[:timeout] || 5_000LexCredo.Check.Warning.UsePositiveTypeGuards
Category: Warning | Priority: High
Flags negated type guards in function heads. A negated guard like not is_nil(x) does not tell you what x is — only what it isn't. Prefer a specific positive guard (is_binary, is_integer, etc.) that accurately constrains the clause.
# flagged
def process(x) when not is_nil(x), do: ...
def process(x) when x != nil, do: ...
# preferred
def process(x) when is_binary(x), do: ...Covers not is_*(), != nil, and !== nil patterns. Recurses through compound and/or guards so all violations in a single guard are reported.
LexCredo.Check.Warning.UseStartSupervised
Category: Warning | Priority: Normal | Test files only
Flags direct start_link/start calls to GenServer, Agent, Task, Supervisor, and DynamicSupervisor in test files. Processes started this way are not automatically cleaned up when the test exits, which can cause interference between tests. Use start_supervised!/1 (or start_supervised/1) instead.
# flagged
{:ok, pid} = GenServer.start_link(MyServer, [])
# preferred
pid = start_supervised!(MyServer)Disabling Checks
To disable a check project-wide, move it to the disabled: list in .credo.exs:
checks: %{
disabled: [
{LexCredo.Check.Warning.NoPipeIntoCase, []},
]
}See General Parameters for per-occurrence suppression and the Credo inline config documentation for all available directives.
Contributing
New checks should follow the same structure as the existing ones: one module per file under lib/lex_credo/check/<category>/, using use Credo.Check and returning a list of issues from run/2. Run mix precommit before opening a pull request — it compiles, formats, runs Credo (including these checks on themselves), and runs the test suite.
mix precommit
License
MIT