Compare

Alchemy vs existing IaC solutions

Most existing IaC solutions wrap terraform or are managed services in some way. Alchemy ships its own IaC engine in TypeScript, opening the door for more fully integrated features: testing, engine, bindings, permissions, and local dev.

At a glance

AlchemySSTPulumiTerraformAWS CDK
LanguageTypeScript + EffectTypeScriptTypeScript, Python, Go, C#, JavaHCLTypeScript, Python, Java, C#, Go
Built onPure TypeScript, end to endPulumi, mostly bridged Terraform providersA Go engine, many providers bridged from TerraformA Go engine and Go provider pluginsCloudFormation, a managed engine inside AWS
App code in the same programYes, as typed Effects and LayersNoNoNoNo
PermissionsDerived from the bindings your code usesGranted by linkUnenforcedUnenforcedGranted by grant⁠*⁠() calls
Runs locallyEmulated on your machineNo, it deploys to the cloudNoNoNo
Tests against real infrastructureDeploy, assert, destroy from bun test or VitestNoNoNoNo

A complete iteration loop for agents

Alchemy Test deploys a real stack, runs tests on that deployment, then tears it down all from the test runner you already use. All agent's have to do to test against real infrastructure is make a code change, then run a test file; this keeps the loop extremely tight and lets ai agents move fast without breaking. Our preview benchmark takes 17 seconds for 1 full loop, no competitor even has a full loop.

Compare the testing loop
deploy~12sby handtest<1sdestroy~4sby handedit~17sper loopmanualoutside the testDIYbuild the harnessinfra onlyapp never tested
test/api.test.ts
const stack = beforeAll(deploy(Stack));
afterAll(destroy(Stack));

test("stores a session", Effect.gen(function* () {
  const { url } = yield* stack;
  const res = yield* HttpClient.post(`${url}/session`);
  expect(res.status).toBe(200);
}));
terminal
# 1. deploy the stage yourself
sst deploy --stage test
# 2. run tests with linked resources
sst shell --stage test -- vitest run
# 3. tear it down yourself, even on failure
sst remove --stage test
test/harness.ts
const stack = await LocalWorkspace.createOrSelectStack({
  stackName: "test", projectName: "api", program,
});
const { outputs } = await stack.up();
// pass outputs to your tests yourself…
await runTests(outputs.url.value);
// …and clean up yourself, even when they fail
await stack.destroy();
tests/api.tftest.hcl
run "creates_table" {
  command = apply
  assert {
    condition = (
      aws_dynamodb_table.sessions.billing_mode == "PAY_PER_REQUEST"
    )
    error_message = "wrong billing mode"
  }
}
# asserts on resource attributes only;
# nothing here calls your running app

One test file closes the loop

beforeAll(deploy(Stack)) stands up real infrastructure, the test calls it, and afterAll(destroy(Stack)) tears it down, even when a test fails.

Tests can't deploy & destroy

sst shell can give tests access to resources via link, but deploying and tearing down the stack still need to be done outside of the test runner.

Make your own test runner

Pulumi exposes and automation api that can deploy and destroy, but passing outputs to tests and cleaning up properly after failures means you need to write your own testing harness.

Tests must be entirely decoupled

Terraform test can assert what resources are deployed, but it doesn't verify whats running in those resources is correct

  • part of the loop
  • possible, you build it
  • can't do what the loop needs

Its own engine, not a wrapper

Every competitor has a core and providers have to talk to that core over a bridge, in some cases even 2. Alchemy just imports providers as typescript and runs them directly, this means alchemy can just deploy resources instead of waiting on events.

Alchemy
alchemy.run.ts
AlchemyTypeScript + Effect
Cloud API

1 runtime · TypeScript

SST
sst.config.ts
SSTGo CLI
Pulumi engineGo
Terraform providerGo, through a bridge
Cloud API

3 layers · TypeScript → Go

Pulumi
index.ts
Pulumi engineGo
Provider pluginoften a bridged Terraform provider
Cloud API

2 layers · gRPC between them

Terraform
main.tf
Terraform coreGo
Provider pluginGo
Cloud API

2 layers · HCL → Go

One line does three jobs

To read a table, a function needs three things that usually live in three different places: a permission, the table's name, and a client. In Alchemy the function's code asks for the capability, and that one line produces all three: its statement in the IAM policy, the table's name, and a typed client.

Show how each tool wires the function
src/api.tsexport default class Api extends Lambda.Function<Api>()(  "Api",  { main: import.meta.url },  Effect.gen(function* () {    const sessions = yield* DynamoDB.Table("Sessions");    const getSession = yield* DynamoDB.GetItem(sessions);    const saveSession = yield* DynamoDB.PutItem(sessions);    return {      fetch: Effect.gen(function* () {        const { Item } = yield* getSession({ Key });        yield* saveSession({ Item: next });      }),    };  }),) {}
# deployed config for ApiRole:  Policy:    Version: "2012-10-17"    Statement:      - Effect: Allow        Action: dynamodb:GetItem        Resource: arn:…:table/api-sessions-8f2c      - Effect: Allow        Action: dynamodb:PutItem        Resource: arn:…:table/api-sessions-8f2cEnvironment:  Variables:    Sessions_tableName: api-sessions-8f2c
// runtime: typed clientsconst getSession: (  request: GetItemRequest,) => Effect<GetItemOutput, GetItemError>const saveSession: (  request: PutItemRequest,) => Effect<PutItemOutput, PutItemError>
  • GetItem grants read and returns getSession
  • PutItem grants write and returns saveSession

One line asks for read access. It grants exactlydynamodb:GetItem on this table, injects the table's name, and returns a typed client.

Add PutItem and its statement, its env var, and its client all follow. Remove a line and all three go with it.

sst.config.tsconst sessions = new sst.aws.Dynamo("Sessions", { … });new sst.aws.Function("Api", {  handler: "src/api.handler",  link: [sessions],});src/api.tsimport { Resource } from "sst";const client = new DynamoDBClient({});export const handler = async () => {  const TableName = Resource.Sessions.name;  const { Item } = await client.send(    new GetItemCommand({ TableName, Key }));  await client.send(    new PutItemCommand({ TableName, Item: next }));};
# deployed config for ApiRole:  Policy:    Version: "2012-10-17"    Statement:      - Effect: Allow        # every DynamoDB action, DeleteTable too        Action: "dynamodb:*"        Resource:          - arn:…:table/app-sessions          - arn:…:table/app-sessions/*Environment:  Variables:    SST_RESOURCE_Sessions: '{"name":"app-sessions",…}'
// runtimeconst TableName: string; // typed by link// no client definition: new DynamoDBClient()// and send() any command, allowed or not
  • more access than the code uses
  • written by hand, unchecked

link writes the policy for you, but it grants dynamodb:* on the table, DeleteTable included, while the handler only calls GetItem and PutItem. There's no client definition, so the SDK calls are yours to keep in sync.

index.tsconst sessions = new aws.dynamodb.Table("sessions", { … });const role = new aws.iam.Role("api", { assumeRolePolicy: … });new aws.iam.RolePolicy("api", {  role: role.id,  policy: sessions.arn.apply((arn) => JSON.stringify({    Version: "2012-10-17",    Statement: [{      Effect: "Allow",      Action: ["dynamodb:GetItem", "dynamodb:PutItem"],      Resource: arn,    }],  })),});new aws.lambda.Function("api", {  role: role.arn,  environment: {    variables: { TABLE_NAME: sessions.name },  },});
# deployed config for apiRole:  Policy:    Version: "2012-10-17"    Statement:      - Effect: Allow        Action: [dynamodb:GetItem, dynamodb:PutItem]        Resource: arn:…:table/sessions-4d1aEnvironment:  Variables:    TABLE_NAME: sessions-4d1a
// runtime: no client definition// the handler builds its own SDK calls// against process.env.TABLE_NAME
  • IAM policy: every line is yours to write
  • env var: yours to wire

Pulumi deploys exactly the policy you define: each statement, action, and ARN in the config traces back to a line you wrote. There's no client definition, so nothing checks your handler's SDK calls against either.

main.tfresource "aws_dynamodb_table" "sessions" { … }resource "aws_iam_role" "api" { assume_role_policy = … }resource "aws_iam_role_policy" "api" {  role   = aws_iam_role.api.id  policy = jsonencode({    Version   = "2012-10-17"    Statement = [{      Effect   = "Allow"      Action   = ["dynamodb:GetItem", "dynamodb:PutItem"]      Resource = aws_dynamodb_table.sessions.arn    }]  })}resource "aws_lambda_function" "api" {  role = aws_iam_role.api.arn  environment {    variables = {      TABLE_NAME = aws_dynamodb_table.sessions.name    }  }}
# deployed config for apiRole:  Policy:    Version: "2012-10-17"    Statement:      - Effect: Allow        Action: [dynamodb:GetItem, dynamodb:PutItem]        Resource: arn:…:table/sessionsEnvironment:  Variables:    TABLE_NAME: sessions
// runtime: no client definition// the handler builds its own SDK calls// against process.env.TABLE_NAME
  • IAM policy: every line is yours to write
  • env var: yours to wire

Terraform deploys exactly the policy you define: each statement, action, and ARN in the config traces back to a line you wrote. There's no client definition, so nothing checks your handler's SDK calls against either.

Principle of least privilege

In Alchemy permission policies are created from your code. This allows least privilege security policies to be inferred on the type level. SST provides link to automate common policy usecases, but are one size fits all, not lease privilege. Pulumi & terraform offer no enforcement, so you have to manually maintain permission policies yourself.

Alchemy
neededDynamoDB.GetItem(table)

grants dynamodb:GetItem on table

Exact

SST
neededlink: [table]

grants dynamodb:* on table

Close

Pulumi & Terraform
neededa policy you write

grants often dynamodb:* on *

Unenforced

  • what the code needs
  • granted, scoped by the tool
  • granted by a policy you maintain

True emulation for faster dev loops

alchemy dev runs your stack entirely on your machine, or in the cloud, or in a hybrid of both, you get to choose. Our competitors lock you into one testing model and force you to accept the compromises they chose.
Or in some cases they don't offer any dev tooling and you just deploy every time like a sucker stuck in the past.

Alchemy0 round tripsRuns offline. Edits reload in place.
YOUR MACHINECLOUDnetworkbrowserLambda · DockerDynamoDB · flocistays on your machine
SST3 round tripsPer request, through a deployed stage. Needs a connection.
YOUR MACHINECLOUDnetworkbrowserAPI GatewayLambda stubyour codeDynamoDB
Pulumi & TerraformDeploy firstNo local runtime. Every change is a redeploy.
YOUR MACHINECLOUDnetworkeditdeployLambdaDynamoDB
  • a trip across the network

And a few more things

  • Providers built from the spec

    Cloudflare, AWS, Fly, PlanetScale, Prisma, Axiom, Stripe, GitHub, Docker, and more. We are constantly adding more providers.

  • Runs anywhere TypeScript runs

    Alchemy is just typescript, it can run on your machine, inside a cloudflare worker, or even in a browser.

  • Bring your own provider

    A provider is just TypeScript: declare the resource's props, then write its create, update, and delete as plain functions. No Go plugin or bridge to build. Write one.