Making an LLM Request in Effect TS
Here is an LLM request written with the Anthropic SDK the way the rest of the TypeScript world would.
If this feels alien, then I suggest starting with this tutorial, where I break the call down in plain TypeScript first.
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
const message = await client.messages.create({ ... });
Observe thy mark of exclamation.
That is not punctuation. It is you, swearing an oath to the type checker that the variable exists.
And the foolish type checker believes you.
So you ship.
The key is absent in production, the oath dissolves at runtime, and you are awoken at 2 a.m. to go harrowing after a 401 that declines to say which of your services forgot its .env.
And what about the await?
It could reject. With what, precisely? The type reads Promise<Message>. Not one peep about any failures.
Verbosity
An Effect<A, E, R> describes work that may succeed with an A, fail with an E, and require services R. All three encoded in the type signature.
So let us begin our work with that key.
import Anthropic from "@anthropic-ai/sdk";
import { Config, Data, Effect, Redacted } from "effect";
const apiKey = Config.redacted("ANTHROPIC_API_KEY");
Hover over apiKey, and you will see Config<Redacted<string>>, not one exclamation mark in sight. If the variable is missing, this will fail with a ConfigError: a value in the Error channel, visible in the type signature of anything that uses it.
const client = Effect.gen(function* () {
const key = yield* apiKey;
return new Anthropic({ apiKey: Redacted.value(key) });
});
Effect.gen is how a procedure is typically written. Each yield* summons an Effect and hands back its success value. A failure short-circuits the rest of the block, without the constant need to clutter our code with messy early return statements.
Redacted is a small courtesy. It prints as <redacted>, so you may log it, stringify it, or paste it into an error report without sharing sensitive information.
Redacted.value is the only place we deliberately unwrap the key, and we do it directly into the constructor that needs it. Ceremony, admittedly, but it is also the exact line you would grep for on the day the key leaks.
Failure
The network is a hostile place, and Anthropic's API has opinions about your billing.
So we must always christen our failures.
class AnthropicFailed extends Data.TaggedError("AnthropicFailed")<{
readonly cause: unknown;
}> {}
Data.TaggedError gives us an error class carrying a _tag — the label Effect uses to tell one failure from another when we handle them.
And now the request.
const send = Effect.fn("send")(function* (messages: Anthropic.MessageParam[]) {
const anthropic = yield* client;
return yield* Effect.tryPromise({
try: () =>
anthropic.messages.create({
model: "claude-sonnet-5",
max_tokens: 1000,
messages,
}),
catch: (cause) => new AnthropicFailed({ cause }),
});
});
Effect.tryPromise takes the Promise-returning call and a catch that says what a rejection means. The rejection stops being an anonymous unknown thrown into the void.
Newly knighted, our AnthropicFailed sits upon the error channel now, where all who compile can see him.
Quick aside: Effect.gen takes no arguments. It takes a generator with zero params and hands back an Effect value. If you need a parameter, you wrap it yourself:
const send = (messages: Anthropic.MessageParam[]) =>
Effect.gen(function* () {
const anthropic = yield* client;
return yield* Effect.tryPromise({ ... });
});
Effect.fnis the same arrow, done for you.
Except with
Effect.fnyou get the Stack trace for telemetry. The "send" string names a tracing span wrapping the whole body. Wire up OpenTelemetry and every call shows up as a timed send span. Makes debugging easier when harrowing after 401s at 2 a.m. Now back to our story...
THREE PARAMETERS
Three things go into the Anthropic call.
modelnames the Claude model doing the work.max_tokensis a safety limit, not a target. Set it to 1000, and Claude stops at 1000 tokens even mid-thought. It doesn't aim for this number; it's a cutoff.messagesis the conversation. This deserves its own section.
Messages
A conversation is a list of messages. Each one is an object with a role and a content.
A user message is the content you send. An assistant message is content Claude generated.
const messages: Anthropic.MessageParam[] = [
{
role: "user",
content: "What is quantum computing? Answer in one sentence",
},
];
Getting the words out
What comes back is a response object thick with an id, a model name, token usage, a stop reason, and, somewhere in there, the sentence you actually asked for.
The sentence lives in message.content[0].text. Under strict with noUncheckedIndexedAccess, that first index is ContentBlock | undefined, and a ContentBlock is not obliged to be text.
So we name that failure too.
class NoTextBlock extends Data.TaggedError("NoTextBlock")<{
readonly stopReason: Anthropic.Message["stop_reason"];
}> {}
And finally, our ask.
const ask = Effect.fn("ask")(function* (messages: Anthropic.MessageParam[]) {
const message = yield* send(messages);
if (message.stop_reason === "max_tokens") {
yield* Effect.logWarning("Hit max_tokens — the answer is a fragment");
}
const block = message.content[0];
if (block?.type !== "text") {
return yield* new NoTextBlock({ stopReason: message.stop_reason });
}
return block.text;
});
stop_reason tells you why our model stopped. When it reads max_tokens, you know the end has been sawn off. Something you want to know before showing it to anyone.
The block?.type !== "text" guard narrows in a single line: past it, block is a text block and block.text is a string. No casts, no assertions.
Running it
Nothing so far has done anything.
Everything has been a mere description. No keys were read, no requests were sent.
To render our imaginings...
const program = Effect.gen(function* () {
const answer = yield* ask(messages);
yield* Effect.log(answer);
});
await Effect.runPromise(program);
Hover over program and it will read:
Effect<void, ConfigError | AnthropicFailed | NoTextBlock, never>
A missing key ConfigError, a refused request AnthropicFailed, and a response with no words in NoTextBlock. Every known way this program could fail, on one line, before it has failed.
Effect.runPromise is the boundary.
We call it once at the edge of our program.
A link to a repo with runnable code at the end of this post, if you want to get this running.
Now you may call our program: pnpm dev:effect
timestamp=2026-08-15T04:31:09.433Z level=INFO fiber=#0 message="Quantum computing is a type of computation that uses the principles of quantum mechanics—such as superposition and entanglement—to process information in ways that can solve certain complex problems faster than classical computers."
Change the prompt. Ask it something else.
Now some will say this is a great deal of pageantry for one HTTP request. All that we did was write down three things that can go wrong. The alternative was not writing them down, and they could still go wrong.
But I hear you, so let us learn to lean on Effect for its conveniences.
pnpm add @effect/ai @effect/ai-anthropic @effect/platform
Three packages. @effect/ai is the core — it knows what a language model is, and refuses to know anything about vendors. @effect/ai-anthropic knows about one vendor in exhaustive detail. @effect/platform supplies the HTTP client they both stand on.
Here is the entire program again.
import { LanguageModel } from "@effect/ai";
import { AnthropicClient, AnthropicLanguageModel } from "@effect/ai-anthropic";
import { FetchHttpClient } from "@effect/platform";
import { Config, Effect, Layer } from "effect";
const program = Effect.gen(function* () {
const response = yield* LanguageModel.generateText({
prompt: "What is quantum computing? Answer in one sentence",
});
if (response.finishReason === "length") {
yield* Effect.logWarning("Hit max_tokens — the answer is a fragment");
}
yield* Effect.log(response.text);
});
Take inventory of what is missing.
- No Anthropic client, so no
AnthropicFailed. Nocontent[0], so noNoTextBlock, no guard, no narrowing. - Our
response.textis a string — the package does the block-spelunking. - The failures we christened by hand arrive pre-christened: HttpRequestError, HttpResponseError, MalformedOutput, each one tagged and sitting in the error channel, exactly as we would have built them.
Our stop_reason check survives. finishReason is provider-agnostic now. Anthropic's max_tokens arrives as "length". And if we hover over finishReason, we now get an exhaustive list of reasons generation might stop.
But the real novelty is when we hover over our program:
We get Effect<void, AiError, LanguageModel>
Look at the third slot.
R is the channel of requirements — services the effect needs but does not name a supplier for. LanguageModel.generateText requires a LanguageModel. Which one? Whose? At what price per token? The program declines to say. It merely provides an interface.
Someone must eventually make good on the interface. That someone is a Layer.
const SonnetLayer = AnthropicLanguageModel.model("claude-sonnet-5", {
max_tokens: 1000,
});```
If the interface were a port, then for our purposes, the Layer is our adapter.
And sometimes Layers need other layers to work.
Look at our `AnthropicClient` layer, it needs its own `HttpClient` to work.
```ts
const Anthropic = AnthropicClient.layerConfig({
apiKey: Config.redacted("ANTHROPIC_API_KEY"),
}).pipe(Layer.provide(FetchHttpClient.layer));
Requirements all the way down, in layers.
await program.pipe(
Effect.provide(Sonnet),
Effect.provide(Anthropic),
Effect.runPromise,
);
Each Effect.provide discharges requirements. Sonnet satisfies LanguageModel and demands AnthropicClient; Anthropic satisfies that. By the time we reach runPromise, the type reads:
Effect
R is never once more. Nothing left unsupplied.
The error channel still confesses to everything: a missing key, and the whole taxonomy of ways an HTTP conversation goes sour.
This time, we wrote nothing down. Everything written for us.
Run it again: pnpm dev:effect
timestamp=2026-08-15T05:43:58.184Z level=INFO fiber=#0 message="Quantum computing is a type of computing that uses the principles of quantum mechanics—such as superposition and entanglement—to process information..."
Swap Sonnet for the OpenAI provider's layer and the program compiles unchanged — same prompt, same finishReason check, same log line.
The vendor is now a deployment decision that gets made at the edge, next to the other deployment decisions.
That is what the ceremony was for.
