Skip to content

Binding

A Binding connects a Resource to a Platform — a Worker, a Lambda Function, a Container. One bind() call generates the IAM policies, the environment variables, and the typed SDK wrapper your handler uses. You don’t write any of that by hand.

This page covers the mechanics. For the bigger picture of how bindings fit into a Platform’s runtime, see Platform › Bindings in action.

const bucket = yield* Cloudflare.R2Bucket.bind(Bucket);
// later, in fetch:
yield* bucket.put("hello.txt", "world");

bucket here is the resource itself, presented as a typed client. There is no env.BUCKET, no BUCKET_NAME lookup — the binding is the SDK.

Bindings work in both handler styles a Platform supports. Pick whichever your Worker / Lambda is using.

Effect stylebind() inside the init Effect, returns a typed handle:

export default Cloudflare.Worker(
"Worker",
{ main: import.meta.filename },
Effect.gen(function* () {
const bucket = yield* Cloudflare.R2Bucket.bind(Bucket);
return {
fetch: Effect.gen(function* () {
const obj = yield* bucket.get("key");
// ...
}),
};
}),
);

Async style — declare bindings on the resource’s env prop, type the env with InferEnv:

alchemy.run.ts
export type WorkerEnv = Cloudflare.InferEnv<typeof Worker>;
export const Worker = Cloudflare.Worker("Worker", {
main: "./src/worker.ts",
env: { Bucket, KV },
});
// src/worker.ts
export default {
async fetch(request: Request, env: WorkerEnv) {
const obj = await env.Bucket.get("key");
// ...
},
};

The rest of this page walks through the Effect style; the same deploy-time mechanics apply to both.

Each call records three things on the platform’s plan:

  1. Permissions — IAM (AWS) or Worker bindings (Cloudflare)
  2. Environment / configuration — physical names, ARNs, URLs
  3. A typed SDK wrapper — bundled into the handler

Each binding maps to specific IAM actions on the exact resource ARNs. Alchemy generates least-privilege policies — Resource: "*" is only used when the API genuinely doesn’t support resource-level scoping.

BindingIAM ActionsResource
S3.GetObject.bind(bucket)s3:GetObjectarn:aws:s3:::bucket-name/*
S3.PutObject.bind(bucket)s3:PutObjectarn:aws:s3:::bucket-name/*
SQS.SendMessage.bind(queue)sqs:SendMessageQueue ARN
DynamoDB.GetItem.bind(table)dynamodb:GetItemTable ARN
DynamoDB.PutItem.bind(table)dynamodb:PutItemTable ARN

Multi-resource bindings enumerate every ARN they touch:

const get = yield* DynamoDB.GetItem.bind(JobsTable, AuditTable);
// → policy enumerates both table ARNs explicitly

Bindings inject the env vars the SDK wrapper needs — BUCKET_NAME, QUEUE_URL, TABLE_ARN, etc. You don’t read these yourself; the typed wrapper takes care of it.

On Cloudflare, the same call attaches a native Worker binding (R2, KV, D1, Durable Object…) instead of an IAM policy:

const bucket = yield* Cloudflare.R2Bucket.bind(Bucket);
const kv = yield* Cloudflare.KVNamespace.bind(Sessions);

The runtime API is identical to the AWS counterpart — code that consumes one consumes the other.

An event source is a binding that triggers your function when something happens on a resource. It hands you the records as an Effect Stream, so the whole event loop becomes a value you can map, filter, batch, and pipe:

yield* SQS.messages(InboundQueue).subscribe((records) =>
records.pipe(
Stream.map((r) => r.body),
Stream.runForEach(Console.log),
),
);

The other event sources have the same shape — only the record type and the entry-point name change:

SourceEntry pointStream element
SQS.messages(queue).subscribe(fn)SQSRecord
Kinesis.records(stream).process(fn)KinesisStreamRecord
DynamoDB.stream(table).process(fn)StreamRecord<T>

One call wires the event-source mapping, the IAM (or Cloudflare binding), and the typed Stream.

A sink is the dual of an event source: a binding for writing that exposes the resource as an Effect Sink. It batches input chunks into the underlying batch API (SendMessageBatch, PutRecords, PublishBatch) and emits the minimal IAM to go with it.

const sink = yield* SQS.QueueSink.bind(OutboundQueue);
// → Sink<void, string, readonly string[], never>
// → policy: sqs:SendMessage + sqs:SendMessageBatch on OutboundQueue's ARN
yield* Stream.fromIterable(["a", "b", "c"]).pipe(Stream.run(sink));

Sinks are typed by the element they accept, so the type system stops you from feeding the wrong shape in: QueueSink and TopicSink take string, StreamSink takes PutRecordsRequestEntry (you keep control of PartitionKey).

Where the model pays off is when you compose them. Because every step is just Stream / Sink, the whole pipe-and-transform pipeline is one expression:

const outbound = yield* SQS.Queue("Outbound");
const sink = yield* SQS.QueueSink.bind(outbound);
yield* DynamoDB.stream(OrdersTable, {
streamViewType: "NEW_AND_OLD_IMAGES",
startingPosition: "LATEST",
}).process<Order>((records) =>
records.pipe(
Stream.filterMap((r) => Option.fromNullable(r.dynamodb.NewImage)),
Stream.filter((order) => order.status === "PAID"),
Stream.map((order) => JSON.stringify({ orderId: order.id })),
Stream.run(sink),
),
);

Two bind() calls, one pipeline. Alchemy generates both sides of the policy automatically — dynamodb:DescribeStream / GetRecords / GetShardIterator on OrdersTable’s stream, sqs:SendMessageBatch on Outbound — plus the event-source mapping. There is no env-var plumbing, no SendMessageBatchCommand chunking by hand, and the sink coalesces the stream into 10-message batches under the hood.

Drop in Effect.retry, Stream.throttle, Stream.groupedWithin, or Stream.mapEffect anywhere along the chain — they’re all the same Stream you’d write in a plain Effect program.

Internally each binding splits into two layers — and which one runs depends on the phase:

  • Binding.Service — the runtime SDK wrapper that gets bundled into your function. This is what bucket.get(...) actually calls.
  • Binding.Policy — the deploy-time logic that emits IAM, Worker bindings, and env vars. This is not included in the runtime bundle.

At plantime the Policy layer is provided, so bind() records what the function needs. At runtime the Policy layer is absent, so the same call resolves to just the lightweight Service wrapper. The runtime bundle stays small because none of the planning code ships.

See Plantime and Runtime › Binding.Service vs Binding.Policy for a deeper look.

Bindings return Effect values. That means Effect.retry, timeout, catchTag — they all just work, with typed error channels:

const sendWithRetry = enqueue({ MessageBody: msg }).pipe(
Effect.retry({ times: 3, schedule: Schedule.exponential("100 millis") }),
Effect.timeout("5 seconds"),
Effect.catchTag("ThrottlingException", () => Effect.succeed(undefined)),
);

Because every binding is an Effect with the same shape, you can hide them behind a service interface and swap implementations without touching handler code. That’s exactly what Layers is about — read on.