TelegramEx.Effect is the execution value used by builders. It keeps the
handler context and the first error produced by a builder step.
Most bot code does not create effects explicitly. A normal handler can still
start with ctx:
ctx
|> Message.text("Hello")
|> Message.send(chat_id)The first builder wraps ctx into an effect. Every following builder receives
that effect, updates it if it is successful, or leaves it unchanged if an error
has already happened.
Shape of an Effect
Conceptually, an effect looks like this:
%TelegramEx.Effect{
ctx: ctx,
result: nil,
error: nil
}:ctxstores the current builder context:errorstores the first failed step:resultis used when an effect needs to carry a handler result
The important rule is simple: successful effects keep moving through the pipeline; failed effects short-circuit later steps.
Wrapping Context
Effect.wrap/1 accepts either a context map or an existing effect:
ctx = %{token: "123:token", payload: %{}}
effect = TelegramEx.Effect.wrap(ctx)
%TelegramEx.Effect{
ctx: %{token: "123:token", payload: %{}},
error: nil,
result: nil
} = effectThis is why builders can accept both ctx and an effect:
Message.text(ctx, "Hello")
Message.silent(Message.text(ctx, "Hello"))Both calls return TelegramEx.Effect.
Updating Context
Use Effect.map_ctx/2 for steps that cannot fail. The function receives the
current context and must return the new context.
alias TelegramEx.Effect
ctx = %{payload: %{}}
effect =
ctx
|> Effect.wrap()
|> Effect.map_ctx(fn ctx ->
payload = Map.put(ctx.payload, :text, "Hello")
Map.put(ctx, :payload, payload)
end)
%Effect{ctx: %{payload: %{text: "Hello"}}, error: nil} = effectThis is the mechanism behind payload-only builders such as Message.text/2,
Message.silent/1, and Document.caption/2.
Running Fallible Steps
Use Effect.then/2 for steps that can fail. The function receives the current
context and must return {:ok, new_ctx} or {:error, reason}.
alias TelegramEx.Effect
read_file = fn ctx ->
case File.read("/tmp/report.pdf") do
{:ok, content} ->
payload = Map.put(ctx.payload, :document, content)
{:ok, Map.put(ctx, :payload, payload)}
{:error, reason} ->
{:error, {:file, reason}}
end
end
effect =
%{payload: %{}}
|> Effect.wrap()
|> Effect.then(read_file)If the file exists, effect.ctx.payload.document contains the file content. If
the file cannot be read, effect.error is {:file, reason}.
Unexpected return values are treated as errors:
effect =
%{payload: %{}}
|> Effect.wrap()
|> Effect.then(fn ctx -> ctx end)
%Effect{error: {:invalid_return_value, %{payload: %{}}}} = effectThis keeps builder internals strict: a fallible step must say whether it succeeded or failed.
Short-Circuiting
After an error, map_ctx/2 and then/2 do not run their functions.
alias TelegramEx.Effect
effect =
%{payload: %{}}
|> Effect.wrap()
|> Effect.then(fn _ctx -> {:error, :missing_file} end)
|> Effect.map_ctx(fn _ctx ->
raise "this function is not called"
end)
|> Effect.then(fn _ctx ->
raise "this function is not called either"
end)
%Effect{error: :missing_file} = effectThis is the core reason builders can stay pipeline-friendly. A local file step can fail, and later caption or send steps can remain in the code without adding manual branching between every builder call.
How Builders Use Effects
Payload builders usually delegate to TelegramEx.Builder.put_payload/3:
def text(input, text) do
TelegramEx.Builder.put_payload(input, :text, text)
endput_payload/3 wraps the input and updates ctx.payload through
Effect.map_ctx/2.
File builders delegate to TelegramEx.Builder.put_file_payload/3:
def path(input, path) do
TelegramEx.Builder.put_file_payload(input, :document, path)
endput_file_payload/3 uses Effect.then/2, because reading from disk can fail.
On failure it stores {:file, reason} in the effect.
Send builders also use Effect.then/2, because Telegram API requests can fail:
def send(input, chat_id) do
input
|> Effect.wrap()
|> Effect.then(fn ctx ->
new_ctx =
ctx
|> Map.put(:chat_id, chat_id)
|> Map.put(:method, "sendMessage")
|> Map.put(:format, :json)
case TelegramEx.API.request(new_ctx) do
:ok -> {:ok, new_ctx}
{:error, reason} -> {:error, reason}
end
end)
endSo a full builder chain is just a sequence of context transformations and fallible steps inside one effect:
ctx
|> Message.text("Report is ready")
|> Message.silent()
|> Message.send(chat_id)Returning Effects
Handlers may return an effect directly:
def handle_message(%{text: "/start", chat: chat}, ctx) do
ctx
|> Message.text("Welcome!")
|> Message.send(chat["id"])
endBefore processing the handler result, the server calls Effect.to_result/1:
Effect.to_result(%Effect{error: nil, result: nil})
#=> :ok
Effect.to_result(%Effect{error: {:file, :enoent}})
#=> {:error, {:file, :enoent}}
Effect.to_result(:pass)
#=> :passThat means effect-based builder pipelines and ordinary handler returns share
the same boundary. Successful sends become :ok; failed sends become
{:error, reason}; FSM return values still work normally.
Explicit Handling
Return the effect directly when the server should handle logging. Match on the effect when the handler needs custom behavior:
case Document.path(ctx, "/tmp/report.pdf") |> Document.send(chat_id) do
%TelegramEx.Effect{error: nil} ->
:ok
%TelegramEx.Effect{error: {:file, reason}} ->
Logger.error("Could not read report: #{inspect(reason)}")
%TelegramEx.Effect{error: reason} ->
Logger.error("Could not send report: #{inspect(reason)}")
endUse explicit handling for retries, cleanup, fallback messages, or business logic that must run only after a successful builder pipeline.