Like Terraform, but in Lean 4 A developer spent two weekends building infra, an infrastructure-as-code tool in Lean 4 that functions like Terraform, supporting three clouds and 14 resource kinds across about 18,000 lines of code. The tool catches many configuration mistakes at compile time, such as invalid regions or instance sizes, while runtime issues like bucket name conflicts remain. The author argues that Lean's dependent types make the compiler a more reliable partner for AI-assisted coding, as compilation provides machine-checkable feedback. I spent two weekends building infra https://github.com/typednotes/infra , an infrastructure-as-code tool in Lean 4. It does what Terraform does: you declare the resources you want, it reads what your cloud accounts actually contain, and it reconciles the difference. Three clouds, fourteen resource kinds, about 18,000 lines of Lean, 126 commits. It was an experiment with one question behind it. How many of the mistakes you normally discover halfway through an apply can be moved into the compiler, if the compiler has dependent types? And does that actually make the loop faster, or does it just move the pain earlier? There is a second reason, and I will state it rather than pretend the first one was enough. Lean 4 is the language I most enjoy writing, and it is the best language I know for working with an AI. Not because it generates Lean well, it generates Python better. Because in Lean "it compiles" carries information. An agent writing Python gives you something plausible; an agent writing Lean gives you something the compiler has already argued with. The feedback loop is short, precise and machine-checkable, and I would rather spend my weekend inside that loop than reading a diff hoping it is right. Infrastructure code is a good place to test the idea, because infrastructure is where a plausible-looking mistake costs money and downtime instead of a stack trace. Here is a complete deployment: fleet exampleQueue in paris where resource scaleway queues "infra-example" { visibilityTimeoutSec := 30 } That is the whole file, plus a one-line main . And the part I did not expect to like as much as I do: in paris could be in warsaw here, and it would compile. In the file next door, which declares resources on both AWS and Scaleway, in warsaw is a compile error, because AWS has no region in Warsaw. Same word, same syntax. Whether it is legal depends on the rest of the file. Where the mistakes are caught The loop is Terraform's: observe, diff, reconcile. What differs is where mistakes are caught. Sorting that out honestly turned out to be most of the design work, and the repo keeps the answer as a table: | Mistake | Caught | How | |---|---|---| | A reference to a resource that does not exist | compile time | there is nothing to write down: a reference can only be one of this file's own resources | | A resource that needs another and names none | compile time | the field has no default, so the resource is not finished without it | | Using a service a cloud does not have | compile time | that cloud has no such resource type, so there is no name for it | | A plan whose shape depends on a value the cloud has not returned yet | compile time | the little expression language cannot branch on one | | An instance size that does not exist | compile time | the compiler works out which sizes the family comes in, and checks | | A region a cloud is not in | compile time | the compiler works out which of your clouds have a region there | | A bucket name someone else already took | runtime | uniqueness is global, not a property of your file | | Quota, capacity, eventual consistency | runtime | not a property of the configuration at all | Two different things happen in those compile-time rows. In the first four the mistake has no spelling: there is no way to write the broken configuration down, so nothing has to be checked. In the next two you can write it down, and the compiler decides by running a small function over what you wrote. Lean people call that second kind an elaboration-time check. For the rest of this post it is just compilation. The last two rows are the honest half of any "types catch bugs" claim. Compiling is not a promise that the apply will succeed. It is a promise about which failures are still on the table when you get there. The differences, listed Before the code, the short version of what this buys over HCL: | Terraform | infra | | |---|---|---| | A reference | a string the graph resolves, typo caught at plan time | a value whose type carries the cloud and the kind, typo has no spelling | | A required reference | providers rarely enforce one, a missing field falls back to a default | no default exists, so the resource is a function still waiting for an argument | | Ordering | derived from expressions, depends on by hand for the rest | derived from references, there is no depends on | | Region | a string per provider block, aliases for more than one | a place, mapped to each cloud's own code, one word places every cloud | | Instance type | a string | a family and a size, and the pair is checked | | Secrets | marked sensitive, redacted from output, written into state | a source, never a value, and no way to print one | | An unknown deciding how many resources exist | a plan-time error you meet one attribute at a time | not expressible, so a plan is always computable | | Your own invariants | a separate linter, in another language, free to disagree | a Lean function the compiler runs while your file elaborates | | The language | HCL | Lean, with its loops, functions, tests and abstraction | | Providers and ecosystem | thousands of resource types, modules, state locking, team workflow | fourteen kinds, three clouds, no registry | The last row is why you should use Terraform this week. The rest is what I think is worth stealing. Side by side A reference that cannot dangle The right-hand pane below is not my invention. toHcl in the repo generates it from the fleet on the left, and I only aligned the = signs. Terraform resolves aws security group.web.id in its graph, so a typo there is caught at plan time. Two things it cannot say. The field is not required, so deleting the line gives you an instance in the default security group rather than an error. And .id is a string by the time the provider sees it, so nothing objects if you pass a subnet id instead. On the left the field is declared once: securityGroup : Field .required o f K .aws .securityGroup Three consequences, and none of them is a check that runs later. Leaving the field out does not compile. Naming a group that is not in this file does not compile, because the only things of that type are the groups declared above it. Passing a bucket does not compile either, because a reference carries the cloud and the kind in its type, and a bucket is not a security group. That last one is the case a string could never catch: both resources are in AWS, both exist, and the names look alike. The first error is my favourite, because of what it is not: js Application type mismatch: The argument fun securityGroup = Build.awsInstance … securityGroup has type Expr ?m ?m ProviderId.aws Kind.securityGroup → AwsInstanceSpec … but is expected to have type SpecOf Kind.awsInstance keys.Key Partial Expr keys.Key A missing required field leaves you holding a function. There is no validation pass that complains later, and no moment at which a group-less instance exists as a value. A size that does not exist, and a place a cloud is not in In HCL, instance type = "t3.nanoo" is a string. Plan succeeds, apply fails with InvalidParameterValue , after the security group it references has been created. region = "eu-west-3" is a string too, so a Scaleway code in an AWS provider block fails at runtime, usually as a DNS error. Neither is really a string. An instance type is a family and a size, both from small closed sets, and the pair is checked: def InstanceType.of f : InstanceFamily s : InstanceSize h : Assert f.sizes.contains s := by decide : InstanceType := ⟨s "{f.code}.{s.code}"⟩ That third argument is the check, and you never write it. by decide tells the compiler to settle the claim by computing it: true and it fills the argument in silently, false and there is nothing to fill it with. So InstanceType.of .t3 .xlarge32 gives: could not synthesize default value for parameter ' h' using tactics Tactic decide proved that the proposition Assert InstanceFamily.t3.sizes.contains InstanceSize.xlarge32 is false 26 families and 17 sizes make 257 valid types, from a table small enough to keep true. It also catches what a curated list of strings gets wrong: gen-7 Intel skips 32xlarge and jumps to 48xlarge , while gen-6 AMD reaches 48 and its Intel sibling does not. A place gets the same treatment, one level up. A locality is a place named before any cloud names it, and each cloud maps it to its own code or to nothing: guard Locality.paris.code .aws = some "eu-west-3" guard Locality.paris.code .scaleway = some "fr-par" guard Locality.warsaw.code .aws = none guard Locality.ireland.code .scaleway = none So one in paris places every cloud a fleet uses, which a region string cannot: The two region strings on the right are two chances to be wrong, and nothing relates them. On the left there is one word, and for a whole fleet the check is that every cloud it uses has a region there. That makes the set of legal placements something you compute rather than maintain: guard Finite.elems α := Locality .filter ·.covers crossCloud.keys = .paris, .milan That is the line I would show first. Nobody wrote that list down. It grew on its own when Scaleway opened Milan. A secret that cannot be committed Nothing in HCL stops password = "hunter2" . Providers mark attributes sensitive, which redacts them from console output and writes them into the state file anyway. A secret's value has a source, and the source has exactly two constructors: inductive SecretSource | fromEnv varName : String | composed value : String deriving DecidableEq, BEq No Repr , no ToJson , no FromJson , on purpose. The hand-written Repr prints