Making an LLM Request in Effect TS
Here is an LLM request written with the Anthropic SDK the way most TypeScript developers would.
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
const message = await client.messages.create({ ... });
If this feels alien, I suggest starting with this tutorial, where I break the call down in plain TypeScript first.
That exclamation mark by the ANTHROPIC_API_KEY, that's not punctuation. It's you, swearing to the type checker that the variable exists.
The type checker believes you, so you ship.
The key is absent in production, and you have to go harrowing after a 401 at 2 a.m. because one of your services forgot its .env.
And what about the await? It could reject. With what? The type just reads Promise<Message>, nothing about any failures.
Trusty Promises
Pretend Effects are just an honest alternative to Promises.
The type signature for this honest promise carries three parts. The first is the success value (the stuff we want), the second covers all errors associated with the promise, and the third is the Requirements channel (that lists out any dependencies). We'll come back to the requirements channel in a bit.
We end up with a type signature that looks like Effect<theStuffWeWant, allPossibleErrors, knownDependencies>. This describes something that may succeed with theStuffWeWant, fails with allPossibleErrors, and requires knownDependencies.
The point is that all three are clearly visible in the type signature.
This means much more confidence when working with this piece of code.
Let's try it out so that you see what I mean.
import Anthropic from "@anthropic-ai/sdk";
import { Config, Data, Effect, Redacted } from "effect";
const apiKey = Config.redacted("ANTHROPIC_API_KEY");
Runnable code set up for you here
If you hover over apiKey, you will see Config<Redacted<string>>. No exclamation marks in sight. If the variable is missing, this will fail with a ConfigError: a value in the apiKey's allPossibleErrors channel, clearly visible in the type signature for anyone or anything that uses it.
const client = Effect.gen(function* () {
const key = yield* apiKey;
return new Anthropic({ apiKey: Redacted.value(key) });
});
Effect.gen is how procedures are typically written. Each yield* summons an Effect and returns its success value. A failure would short-circuit the block, so there's no need to clutter the code with 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.
Overt Failure
The network is a hostile place, and Anthropic's API has opinions about how you will be billed.
So we give each known failure mode its own name.
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 for 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. This way the rejection stops being an anonymous unknown.
Now our AnthropicFailed error sits in the allPossibleErrors channel for everyone to see.
Quick aside:
Effect.gentakes 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, just 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.
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",
},
];
What comes back is a response object with an id, a model name, token usage, a stop reason, and, somewhere in there, the sentence you 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'll name that possible failure mode 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 these 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, all on one line.
Effect.runPromise is the boundary.
We call it once at the edge of our program.
A reminder that there's a link to a repo with runnable code at the end of this post, if you want to get this running.
Now, if you call the 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.
Some might say this is a lot of work for one HTTP request. I'd argue that all 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 lean on Effect for some of 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, but 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 a look at what's 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 arrive pre-christened: HttpRequestError, HttpResponseError, MalformedOutput, each one tagged and sitting in the error channel.
Our stop_reason check survives. The 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.
The real novelty is when we hover over our program:
We get Effect<void, AiError, LanguageModel>
Look at the third slot.
That last channel lists out 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 doesn't say. It merely provides an interface.
Someone has to eventually make good on the interface. That's where Layers come in.
const SonnetLayer = AnthropicLanguageModel.model("claude-sonnet-5", {
max_tokens: 1000,
});
If the interface were a port, then the Layer is our adapter.
Sometimes a Layer will need other layers to work.
Look at AnthropicClient, for example; it needs its own HttpClient to work.
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<void, AiError | ConfigError, never>
That last channel is never once again. Nothing left unsupplied.
The error channel still highlights the possibility of a missing key, and the whole taxonomy of ways an HTTP conversation could go wrong.
But this time, we wrote nothing down. Everything was 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 all the ceremony was for.
Repo
Runnable code set up for you here here
