Input Variables as Contracts
Scenario: A module's author gets tired of writing types and declares a variable as accepting any, and it fails deep inside an unrelated resource block months later because a caller passed a list where a structured object was actually expected.
A variable block is how a module (including your root module) declares an input it needs from the outside. Think of it the way you'd think of a function's parameter list in any programming language: it's a public contract describing exactly what a caller must provide, and a well-designed contract catches a bad caller at the door, with a clear error, rather than letting the bad value travel deep into the configuration and fail somewhere confusing.
Three tools make that contract strong. First, a precise type constraint - not just string when you actually need object({ name = string, port = number }), because "any old string" and "a well-formed service definition" are very different promises. Second, a thoughtful default - only set one when there's a genuinely safe, universal choice; a default that's only safe for some callers is worse than no default, because it invites a caller to skip a decision they actually needed to make. nullable (whether null is an acceptable value) should also be a deliberate choice, not an accident. Third, a validation block that encodes domain rules a type alone can't express - a type system can check "is this a number," but only a validation rule can check "is this number a valid TCP port." Grouping related fields into one object type (rather than several parallel top-level variables) also makes a module's evolution much easier to reason about later.
Analogy: Avariableblock is an API schema. A strongly-typed, validated schema rejects a malformed request right at the front door with a clear message; a loosely-typedanyschema lets the malformed request wander in and fail unpredictably deep inside, often inside a distant provider call that gives you a much less helpful error.
A worked configuration
variable "service" {
type = object({
name = string
port = number
tier = optional(string, "standard")
})
validation {
condition = var.service.port >= 1 && var.service.port <= 65535
error_message = "service.port must be a valid TCP port."
}
}
In a production module meant for reuse, deliberately separate operator choices (things a caller should genuinely decide, like environment or instance size) from derived facts (things the module should compute itself from those choices, like a naming convention). Every variable you expose becomes a long-term compatibility promise to every caller - removing or renaming one later is a breaking change. On the flip side, a module that mechanically exposes every single underlying provider argument as its own variable isn't really adding a useful opinion; it's just a thin, high-maintenance wrapper with a much larger support surface than a more deliberately-designed interface would have.
Common mistakes in production
- Typing a variable as
any"to be flexible" - it defers a type error from a clear message at plan time to a confusing failure deep inside a provider call. - Giving a variable a default that's only safe for some use cases (e.g. defaulting
environmentto"prod") - a caller who forgets to override it gets a dangerous surprise instead of a clear reminder to decide.
Goal: Treat the plan as a change contract: understand every create, update, replacement, and destroy before approving it. Reinforce this lesson in the tf-variable-validation Terraform lab. Open/labs/terraformand choose slugtf-variable-validation; the lab runs real Terraform against the offline FakeCloud provider.