Skip to content

Bindings

A Binding connects a Resource to a Worker, Lambda Function, or Container. You yield the resource and get back a typed client:

const bucket = yield* Cloudflare.R2.ReadWriteBucket(Bucket);

Everything else the connection needs is derived from this line:

  1. permission grant — least-privilege IAM on AWS, a native binding on Cloudflare
  2. environment configuration — points the client at the deployed resource: a table ARN, a queue URL, a sensitive API key
export default Cloudflare.Worker(
"Api",
{ main: import.meta.url },
Effect.gen(function* () {
const bucket = yield* Cloudflare.R2.ReadWriteBucket(Bucket);
return {
fetch: Effect.gen(function* () {
yield* bucket.put("hello.txt", "world");
// ...
}),
};
}).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),
);

yield* ReadWriteBucket(Bucket) declares the binding — bucket is the resource itself, presented as a typed client. The binding is the SDK.

Effect.provide(ReadWriteBucketBinding) on the last line chooses how the binding is implemented.

A binding has two halves. The declaration is a contract:

const bucket = yield* Cloudflare.R2.ReadWriteBucket(Bucket);

ReadWriteBucket is a Binding.Service — a callable Context tag. It names the capability and says nothing about how it’s satisfied:

export interface ReadWriteBucket extends Binding.Service<
ReadWriteBucket,
"Cloudflare.R2.ReadWriteBucket",
(bucket: Bucket) => Effect.Effect<ReadWriteBucketClient>
> {}

The implementation is a Layer, and any Layer that satisfies the contract can be provided. Cloudflare ships two interchangeable ones for R2:

}).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),
}).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketHttp)),

ReadWriteBucketBinding registers a native r2_bucket binding on the Worker and reads it off the Worker env at runtime. ReadWriteBucketHttp mints a scoped AccountApiToken at deploy time and calls R2 over HTTP at runtime. Same contract, same handler code.

ReadWriteBucketBinding requires WorkerEnvironment and Worker, so only a Cloudflare Worker can provide it:

export default AWS.Lambda.Function(
"Api",
{ main: import.meta.url },
handler.pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),
// ✗ Type error: the Layer requires `WorkerEnvironment` and `Worker`,
// which a Lambda Function can't provide
);

On AWS, each capability is a single Layer — DynamoDB.GetItemHttp, S3.GetObjectHttp — that attaches least-privilege IAM statements at deploy time and calls the service over HTTP at runtime.

Layers builds on this same tag/Layer split, one level up.

The implementation Layer records three things on the plan:

  1. Permissions — IAM policies (AWS) or native bindings (Cloudflare)
  2. Configuration — physical names, ARNs, URLs, serialized into the Function’s environment
  3. A typed SDK client — bundled into the handler

Each binding maps to specific IAM actions on the exact resource ARNs:

Binding IAM Actions Resource
S3.GetObject(bucket) s3:GetObject arn:aws:s3:::bucket-name/*
S3.PutObject(bucket) s3:PutObject arn:aws:s3:::bucket-name/*
SQS.SendMessage(queue) sqs:SendMessage Queue ARN
DynamoDB.GetItem(table) dynamodb:GetItem Table ARN
DynamoDB.PutItem(table) dynamodb:PutItem Table ARN

Multi-resource bindings enumerate every ARN they touch:

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

Bindings serialize the resolved Outputs the client needs — the queue URL, the bucket name, the table name — into the Function’s environment. The typed client resolves them for you.

Every binding is also invocable at plan time — the shape Terraform calls a data source and Pulumi an invoke. Capability.execute(...) runs the operation during plan/deploy resolution (with the stack’s services, not a deployed host) and returns an Output you can feed straight into resource props:

// the raw invoke — Output<ec2.Image | undefined>
const image = AWS.EC2.getAmi({ owners: ["amazon"], name: ["al2023-ami-2023.*"] });
// helpers built on it — Output<string>, dies when nothing matches
imageId: AWS.EC2.amazonLinux2023(),

Constructing the Output is inert, so execute is safe in composition code that re-executes inside a deployed bundle. The same capability bound inside a Function (yield* AWS.EC2.GetAmi(...)) still grants its IAM and runs at runtime — one contract, both phases. The capability’s implementation layer must be registered on the stack; the cloud providers() layers include their plan-executable capabilities.

An Event Source runs your Function when something happens on a resource — the records arrive as an Effect Stream. A Sink is the write side — the resource exposed as an Effect Sink. Both wire their own permissions, like every other binding:

// Event Source: run this Function on queue messages.
yield* SQS.consumeQueueMessages(InboundQueue, (records) => /* Stream */);
// Sink: write to a queue by running a Stream into it.
const sink = yield* SQS.QueueSink(OutboundQueue);

The deploy-time wiring lives in the binding’s setup Effect, fenced behind a guard:

if (!globalThis.__ALCHEMY_RUNTIME__) {
// deploy-time only: register IAM / native bindings / env on the host
yield* host.bind`${resource}`(/* … */);
}
// always: return the typed runtime client

Phases covers the guard in depth.

Bindings return Effect values, so Effect.retry, timeout, and catchTag 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)),
);

And because every binding has the same shape, you can hide one behind a service interface and swap implementations without touching handler code — that’s Layers. Two Functions can even bind each other — Circular Bindings.

  • Event Sources — bindings that trigger your Function. Next page.
  • Sinks — bindings you write Streams into.
  • Phases — when the deploy-time wiring runs vs the runtime client.
  • Layers — hide bindings behind a service interface.