# Creating Custom Filters
Learn how to extend Mau with custom filters for domain-specific functionality.
## Overview
Custom filters allow you to extend Mau with application-specific logic. Filters are functions that transform values and can be used in templates via the pipe syntax.
## Basic Filter Structure
All filters follow this pattern:
```elixir
defmodule MyApp.CustomFilters do
@moduledoc """
Custom filters for MyApp.
"""
@doc """
Returns the filter specification.
"""
def spec do
%{
category: :custom,
description: "Custom filters for MyApp",
filters: %{
"my_filter" => %{
description: "Description of what the filter does",
function: {__MODULE__, :my_filter}
}
}
}
end
@doc """
Filter implementation.
"""
def my_filter(value, args) do
{:ok, transformed_value}
end
end
```
## Filter Signature
All filter functions must follow this signature:
```elixir
def filter_name(value, args) :: {:ok, result} | {:error, reason}
```
- **value**: The input value to transform
- **args**: List of filter arguments
- **Returns**: `{:ok, result}` on success or `{:error, reason}` on failure
## Simple Filter Example
Create a filter that formats currency values:
```elixir
defmodule MyApp.CurrencyFilters do
@moduledoc """
Filters for currency formatting.
"""
def spec do
%{
category: :currency,
description: "Currency formatting filters",
filters: %{
"currency" => %{
description: "Formats number as currency",
function: {__MODULE__, :currency}
},
"currency_short" => %{
description: "Formats number as short currency (K, M, B)",
function: {__MODULE__, :currency_short}
}
}
}
end
@doc """
Formats a number as USD currency.
"""
def currency(value, _args) when is_number(value) do
formatted = :erlang.float_to_binary(value, decimals: 2)
{:ok, "$#{formatted}"}
end
def currency(value, _args) do
{:error, "currency filter requires a number"}
end
@doc """
Formats large numbers with K, M, B suffixes.
"""
def currency_short(value, _args) when is_number(value) do
cond do
value >= 1_000_000_000 ->
{:ok, "$#{Float.round(value / 1_000_000_000, 1)}B"}
value >= 1_000_000 ->
{:ok, "$#{Float.round(value / 1_000_000, 1)}M"}
value >= 1_000 ->
{:ok, "$#{Float.round(value / 1_000, 1)}K"}
true ->
{:ok, "$#{value}"}
end
end
def currency_short(_value, _args) do
{:error, "currency_short filter requires a number"}
end
end
```
**Usage in templates:**
```elixir
context = %{"price" => 1234.56, "revenue" => 1_500_000_000}
Mau.render("{{ price | currency }}", context)
# Output: "$1234.56"
Mau.render("{{ revenue | currency_short }}", context)
# Output: "$1.5B"
```
---
## Filter with Arguments
Create a filter that accepts parameters:
```elixir
defmodule MyApp.StringFilters do
def spec do
%{
category: :string,
description: "String manipulation filters",
filters: %{
"mask" => %{
description: "Masks part of a string",
function: {__MODULE__, :mask}
},
"repeat" => %{
description: "Repeats a string N times",
function: {__MODULE__, :repeat}
}
}
}
end
@doc """
Masks a string, showing only first and last N characters.
Usage: {{ email | mask(3) }}
"john@example.com" -> "joh***@example.***"
"""
def mask(value, [num]) when is_binary(value) and is_integer(num) do
cond do
String.length(value) <= num * 2 ->
{:ok, String.duplicate("*", String.length(value))}
true ->
first = String.slice(value, 0, num)
last = String.slice(value, -num..-1)
middle = String.duplicate("*", String.length(value) - num * 2)
{:ok, first <> middle <> last}
end
end
def mask(_value, _args) do
{:error, "mask requires a string and number argument"}
end
@doc """
Repeats a string N times.
Usage: {{ word | repeat(3) }}
"Ha" -> "HaHaHa"
"""
def repeat(value, [count]) when is_binary(value) and is_integer(count) and count > 0 do
{:ok, String.duplicate(value, count)}
end
def repeat(_value, _args) do
{:error, "repeat requires a string and positive integer"}
end
end
```
**Usage:**
```elixir
context = %{"email" => "john@example.com", "word" => "Ha"}
Mau.render("{{ email | mask(3) }}", context)
# Output: "joh***@example.***"
Mau.render("{{ word | repeat(3) }}", context)
# Output: "HaHaHa"
```
---
## Complex Filter with Multiple Arguments
Create a filter for string replacement with options:
```elixir
defmodule MyApp.AdvancedFilters do
def spec do
%{
category: :advanced,
description: "Advanced transformation filters",
filters: %{
"replace_multiple" => %{
description: "Replace multiple patterns",
function: {__MODULE__, :replace_multiple}
},
"slugify" => %{
description: "Convert to URL-friendly slug",
function: {__MODULE__, :slugify}
}
}
}
end
@doc """
Replace multiple patterns in a string.
Usage: {{ text | replace_multiple(search1, replace1, search2, replace2) }}
"""
def replace_multiple(value, args) when is_binary(value) and is_list(args) do
if rem(length(args), 2) == 0 do
result = Enum.reduce(Enum.chunk_every(args, 2), value, fn [search, replace], acc ->
String.replace(acc, to_string(search), to_string(replace))
end)
{:ok, result}
else
{:error, "replace_multiple requires pairs of (search, replace) arguments"}
end
end
def replace_multiple(_value, _args) do
{:error, "replace_multiple requires a string and argument pairs"}
end
@doc """
Convert string to URL-friendly slug.
Usage: {{ title | slugify }}
"Hello World!" -> "hello-world"
"""
def slugify(value, _args) when is_binary(value) do
slugified =
value
|> String.downcase()
|> String.replace(~r/[^\w\s-]/u, "")
|> String.trim()
|> String.replace(~r/\s+/u, "-")
|> String.replace(~r/-+/, "-")
{:ok, slugified}
end
def slugify(_value, _args) do
{:error, "slugify requires a string"}
end
end
```
**Usage:**
```elixir
context = %{
"text" => "Hello {{name}} from {{company}}!",
"title" => "My Awesome Blog Post!"
}
Mau.render("{{ text | replace_multiple('{{name}}', 'Alice', '{{company}}', 'Acme') }}", context)
# Output: "Hello Alice from Acme!"
Mau.render("{{ title | slugify }}", context)
# Output: "my-awesome-blog-post"
```
---
## Conditional Filter Logic
Create a filter with conditional behavior:
```elixir
defmodule MyApp.ConditionalFilters do
def spec do
%{
category: :conditional,
description: "Conditional filters",
filters: %{
"pluralize" => %{
description: "Pluralize words based on count",
function: {__MODULE__, :pluralize}
},
"human_filesize" => %{
description: "Convert bytes to human-readable format",
function: {__MODULE__, :human_filesize}
}
}
}
end
@doc """
Pluralize a word based on count.
Usage: {{ count | pluralize('item') }}
1 -> "1 item"
5 -> "5 items"
"""
def pluralize(count, [word]) when is_integer(count) and is_binary(word) do
suffix = if count == 1, do: "", else: "s"
{:ok, "#{count} #{word}#{suffix}"}
end
def pluralize(_value, _args) do
{:error, "pluralize requires a number and word"}
end
@doc """
Convert bytes to human-readable file size.
Usage: {{ filesize | human_filesize }}
1024 -> "1 KB"
1048576 -> "1 MB"
"""
def human_filesize(bytes, _args) when is_integer(bytes) and bytes >= 0 do
cond do
bytes < 1024 ->
{:ok, "#{bytes} B"}
bytes < 1024 * 1024 ->
{:ok, "#{Float.round(bytes / 1024, 1)} KB"}
bytes < 1024 * 1024 * 1024 ->
{:ok, "#{Float.round(bytes / (1024 * 1024), 1)} MB"}
true ->
{:ok, "#{Float.round(bytes / (1024 * 1024 * 1024), 2)} GB"}
end
end
def human_filesize(_value, _args) do
{:error, "human_filesize requires a non-negative integer"}
end
end
```
**Usage:**
```elixir
context = %{"items" => 1, "more_items" => 5, "filesize" => 1048576}
Mau.render("You have {{ items | pluralize('item') }}", context)
# Output: "You have 1 item"
Mau.render("You have {{ more_items | pluralize('item') }}", context)
# Output: "You have 5 items"
Mau.render("File size: {{ filesize | human_filesize }}", context)
# Output: "File size: 1 MB"
```
---
## Registering Custom Filters
Add your custom filters to the application configuration:
```elixir
# config/config.exs
config :mau,
filters: [
MyApp.CurrencyFilters,
MyApp.StringFilters,
MyApp.AdvancedFilters,
MyApp.ConditionalFilters
],
enable_runtime_filters: true
```
Or enable runtime filter loading in your application supervisor:
```elixir
# lib/my_app/application.ex
defmodule MyApp.Application do
use Application
def start(_type, _args) do
children = [
# ... other services
Mau.FilterRegistry
]
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
end
```
---
## Testing Custom Filters
Write tests for your custom filters:
```elixir
defmodule MyApp.CustomFiltersTest do
use ExUnit.Case
test "currency filter formats numbers" do
assert {:ok, "$1234.56"} = MyApp.CurrencyFilters.currency(1234.56, [])
assert {:ok, "$0.99"} = MyApp.CurrencyFilters.currency(0.99, [])
end
test "currency filter rejects non-numbers" do
assert {:error, _} = MyApp.CurrencyFilters.currency("not a number", [])
end
test "mask filter hides middle characters" do
assert {:ok, "joh***@example.***"} = MyApp.StringFilters.mask("john@example.com", [3])
end
test "slugify converts to URL-friendly format" do
assert {:ok, "my-awesome-post"} = MyApp.AdvancedFilters.slugify("My Awesome Post!", [])
end
test "pluralize handles singular and plural" do
assert {:ok, "1 item"} = MyApp.ConditionalFilters.pluralize(1, ["item"])
assert {:ok, "5 items"} = MyApp.ConditionalFilters.pluralize(5, ["item"])
end
end
```
---
## Best Practices
### 1. Error Handling
Always validate inputs and return meaningful error messages:
```elixir
def my_filter(value, args) do
case validate_input(value, args) do
{:ok, cleaned_value} -> process(cleaned_value)
{:error, reason} -> {:error, reason}
end
end
defp validate_input(value, args) when is_number(value) and is_list(args) do
{:ok, value}
end
defp validate_input(_value, _args) do
{:error, "my_filter requires a number and list of arguments"}
end
```
### 2. Type Coercion
Handle type conversions gracefully:
```elixir
def my_filter(value, args) do
try do
numeric_value = value |> to_string() |> String.to_integer()
{:ok, numeric_value * 2}
rescue
_ -> {:error, "Could not convert value to number"}
end
end
```
### 3. Documentation
Include clear documentation with usage examples:
```elixir
@doc """
Transforms text for social media.
Converts to lowercase, removes special characters, and
truncates to specified length (default: 140).
Usage:
{{ text | social_format }}
{{ text | social_format(280) }}
Returns:
{:ok, transformed_text}
{:error, reason}
"""
def social_format(value, args) do
# implementation
end
```
### 4. Performance Considerations
For filters applied to large datasets, optimize for speed:
```elixir
def my_filter(value, _args) when is_list(value) do
# Use Enum.reduce for better performance with large lists
result = Enum.reduce(value, [], &process_item/2)
{:ok, result}
end
defp process_item(item, acc) do
# Efficient processing
[transformed | acc]
end
```
### 5. Composability
Design filters that work well in chains:
```elixir
def my_filter(value, args) when is_binary(value) do
value
|> String.downcase() # Works as first filter
|> String.trim() # Returns binary
|> String.split(" ") # Returns list
|> Enum.map(&capitalize/1) # Can be used in pipe
|> Enum.join(" ") # Back to binary
|> then(&{:ok, &1}) # Return wrapped result
end
```
---
## Advanced: Stateful Filters
Create filters that maintain state (use with caution):
```elixir
defmodule MyApp.StatefulFilters do
def spec do
%{
category: :stateful,
description: "Filters with state",
filters: %{
"with_counter" => %{
description: "Appends counter value",
function: {__MODULE__, :with_counter}
}
}
}
end
@doc """
Appends a counter to the value.
Uses process dictionary for state.
"""
def with_counter(value, _args) do
counter = Process.get(:filter_counter, 0)
new_counter = counter + 1
Process.put(:filter_counter, new_counter)
{:ok, "#{value}-#{new_counter}"}
end
end
```
---
## Common Filter Patterns
### Map Transformation
```elixir
def transform_items(items, [key]) when is_list(items) do
result = Enum.map(items, &Map.get(&1, key))
{:ok, result}
end
```
### Filtering Collection
```elixir
def by_status(items, [status]) when is_list(items) do
result = Enum.filter(items, &(&1["status"] == status))
{:ok, result}
end
```
### Aggregation
```elixir
def sum_field(items, [field]) when is_list(items) do
result =
items
|> Enum.map(&(Map.get(&1, field, 0)))
|> Enum.filter(&is_number/1)
|> Enum.sum()
{:ok, result}
end
```
---
## Troubleshooting
**Filter not found**: Ensure filter module is registered in config
**Type errors**: Check input validation in filter function
**Performance issues**: Use `:timer.tc` to benchmark custom filters
```elixir
{time_us, result} = :timer.tc(fn -> my_filter(value, args) end)
IO.puts("Filter took #{time_us / 1000} ms")
```
---
## See Also
- [Custom Functions](custom-functions.md) - Creating custom functions
- [Filters Guide](../guides/filters.md) - Using filters in templates
- [Filters List](../reference/filters-list.md) - Built-in filters reference
- [Performance Tuning](performance-tuning.md) - Optimization techniques