PipeAssign provides a macros for capturing intermediate values in Elixir pipe chains without breaking the flow or requiring separate assignment statements.
This project was created for research purposes. To understand how assign_to/2
and match_to/2
will affect codebase:
- What performance overhead would be?
- What readability would be, worse or better?
Traditional Elixir code often forces you to choose between clean pipe flow and intermediate value access:
# Clean pipes, but no intermediate access
final_result = data |> transform() |> process() |> finalize()
# Intermediate access, but broken flow
step1 = data |> transform()
step2 = step1 |> process()
final_result = step2 |> finalize()
PipeAssign bridges this gap by allowing you to capture values while maintaining the elegance of pipe operators:
import PipeAssign
data
|> transform()
|> assign_to(step1) # Capture without breaking flow
|> process()
|> assign_to(step2)
|> finalize()
|> assign_to(result) # Assign to result variable
Now you have both clean pipe flow, access to step1, step2, result variables and no assignment before pipes.
In Elixir =
is a match operator. So we can match against it:
iex> import PipeAssign
iex> %{a: 1, b: 2}
...> |> match_to(%{a: x})
%{b: 2, a: 1}
iex> x
1
assign_to/2
is the same as match_to/2
:
# These are equivalent
value |> match_to(result)
value |> assign_to(result)
This is just for readability.
Add pipe_assign
to your list of dependencies in mix.exs
:
def deps do
[
{:pipe_assign, "~> 1.1"}
]
end
Import the module and use assign_to/2
in your pipes:
import PipeAssign
[1, 2, 3]
|> Enum.map(&(&1 * 2))
|> Enum.sum()
|> assign_to(result)
# result == 12
Chain multiple assignments in a single pipe:
import PipeAssign
%{name: "John", age: 30}
|> Map.put(:email, "[email protected]")
|> assign_to(with_email)
|> Map.put(:active, true)
|> assign_to(complete)
|> Map.keys()
|> length()
|> assign_to(result)
# result == 4
# with_email == %{name: "John", age: 30, email: "[email protected]"}
# complete == %{name: "John", age: 30, email: "[email protected]", active: true}
Use require
to call macros with fully qualified name:
require PipeAssign
[1, 2, 3, 4, 5]
|> Enum.filter(&rem(&1, 2) == 0)
|> PipeAssign.assign_to(evens)
# evens == [2, 4]
import PipeAssign
def process_user_data(raw_data) do
raw_data
|> Jason.decode!()
|> assign_to(parsed_json)
|> normalize_keys()
|> assign_to(normalized)
|> validate_required_fields()
|> assign_to(validated)
|> save_to_database()
|> assign_to(result)
Logger.info("Processed user data", %{
raw_size: byte_size(raw_data),
parsed_keys: Map.keys(parsed_json),
normalized_count: map_size(normalized),
validation_status: validated.status
})
result
end
import PipeAssign
def fetch_and_process_posts(user_id) do
user_id
|> fetch_user_posts()
|> assign_to(raw_posts)
|> Enum.filter(&(&1.published))
|> assign_to(published_posts)
|> Enum.sort_by(&(&1.created_at), :desc)
|> assign_to(sorted_posts)
|> Enum.take(10)
|> format_for_api()
|> tap(fn _ ->
Analytics.track("posts_fetched", %{
user_id: user_id,
total_posts: length(raw_posts),
published_posts: length(published_posts),
returned_posts: length(sorted_posts)
})
end)
end
Based on benchmarks with MacBook Air M1 16GB, the overhead are within the margin of error.
PipeAssign includes a comprehensive benchmarking suite to help you understand the performance implications in your specific use cases.
# Quick comparison (recommended for most users)
mix benchmark
# Comprehensive benchmark suite (takes longer)
mix benchmark --full
# Specific benchmark types
mix benchmark --type=hotpath # Performance-critical scenarios
mix benchmark --type=complex # Multi-step pipelines
mix benchmark --type=string # String processing
mix benchmark --type=list # List operations
mix benchmark --type=map # Map manipulations
The benchmarks compare assign_to/2
against traditional assignment patterns across various scenarios. All performance measurements were conducted on MacBook Air M1 16GB running macOS.
- Hot path scenarios: Simple operations where overhead is most visible
- Complex pipelines: Multi-step transformations where overhead is proportionally smaller
- Different data sizes: Small, medium, and large datasets
- Various data types: Lists, strings, maps, and mixed operations
Latest benchmark results (MacBook Air M1 16GB, macOS) show:
Name ips average deviation median 99th %
Hot Path Traditional 7.84 M 127.63 ns ±10284.16% 125 ns 125 ns
Hot Path assign_to/2 7.83 M 127.72 ns ±9866.55% 125 ns 125 ns
String assign_to/2 21.44 K 46.65 μs ±10.07% 46.96 μs 57.04 μs
String Traditional 21.34 K 46.87 μs ±9.62% 47.29 μs 57.08 μs
Complex assign_to/2 25.47 K 39.27 μs ±11.90% 40.21 μs 52 μs
Complex Traditional 25.38 K 39.40 μs ±12.26% 40.42 μs 52 μs
List Traditional 54.28 K 18.42 μs ±22.83% 18.21 μs 20.96 μs
List assign_to/2 54.05 K 18.50 μs ±22.93% 18.25 μs 21.38 μs
Map Traditional 239.71 K 4.17 μs ±204.30% 4.04 μs 6.04 μs
Map assign_to/2 239.09 K 4.18 μs ±200.79% 4.08 μs 5.96 μs
Comparison:
Hot Path Traditional 7.84 M
Hot Path assign_to/2 7.83 M - 1.00x slower +0.0906 ns
String assign_to/2 21.44 K
String Traditional 21.34 K - 1.00x slower +0.22 μs
Complex assign_to/2 25.47 K
Complex Traditional 25.38 K - 1.00x slower +0.135 μs
List Traditional 54.28 K
List assign_to/2 54.05 K - 1.00x slower +0.0780 μs
Map Traditional 239.71 K
Map assign_to/2 239.09 K - 1.00x slower +0.0108 μs
The library includes comprehensive test coverage. Run the tests with:
mix test
To check test coverage:
mix test --cover
Tests automatically run against a matrix of OTP and Elixir Versions, see the ci.yml for details.
OTP \ Elixir | 1.15 | 1.16 | 1.17 | 1.18 |
---|---|---|---|---|
25 | ✅ | ✅ | ✅ | ✅ |
26 | ✅ | ✅ | ✅ | ✅ |
27 | N/A | N/A | ✅ | ✅ |
Full documentation is available at https://hexdocs.pm/pipe_assign.
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.