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: A variable block is an API schema. A strongly-typed, validated schema rejects a malformed request right at the front door with a clear message; a loosely-typed any schema 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

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/terraform and choose slug tf-variable-validation; the lab runs real Terraform against the offline FakeCloud provider.