Chapter 4: Variables, Outputs, and Expressions
Topic 23

Variable Types and Validation

InputsValidation

Terraform has a real type system — primitives (string, number, bool), collections (list, set, map), and structural types (object, tuple) — plus validation blocks that reject bad input before any apply runs. Strong types and validation turn a module into a contract: callers get an immediate, clear error instead of a confusing failure 40 resources deep into an apply.

The payoff is moving failures left. A string typed as a string accepts anything; an object with validation accepts exactly the shape you meant. The difference between those two is whether a caller's mistake surfaces at plan time with a sentence telling them what's wrong, or at apply time as a cryptic provider error after half the stack already changed.

Primitive and Collection Types

The primitives are string, number, and bool. The collections wrap a single element type: list(string) is ordered and allows duplicates, set(string) is unordered and deduplicated, and map(string) is key-value with named access. The choice is not cosmetic — it changes how the value behaves in a plan and how you access it.

list vs set vs map
list
Ordered, allows duplicates. Accessed by numeric index — use when position carries meaning.
set
Unordered, deduplicated. No stable index — the natural source for for_each, avoids reorder churn.
map
Key-value pairs accessed by name. Use when each element needs a stable identifier.
variables.tf — primitives and collections
variable "desired_count" {
  type = number
}

variable "availability_zones" {
  type = list(string)
}

variable "resource_tags" {
  type = map(string)
}

Structural Types

An object({...}) describes a value with named attributes of mixed types, and optional() marks an attribute the caller may omit, supplying a default when they do. This is how a module accepts one richly-shaped input instead of a dozen loose variables. The block below takes a single app_config object where instance_type defaults to t3.micro if the caller leaves it out.

variables.tf — an object with optional attributes
variable "app_config" {
  type = object({
    name          = string
    instance_type = optional(string, "t3.micro")
    min_size      = number
    public        = optional(bool, false)
  })
}

Validation Blocks

A validation block runs a condition against the input and, if it fails, halts the plan with your error_message. Use it for ranges, allowed values, and formats — anything where a wrong value is knowable before apply. The validation below rejects an environment that isn't one of three allowed strings, and the message tells the caller exactly what's valid.

variables.tf — a validation block
variable "environment" {
  type = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be one of: dev, staging, prod."
  }
}

Type Conversion and Nullability

Terraform converts between compatible types automatically — a number flows into a string context, a list converts to a set — and the surprises come when an implicit conversion isn't the one you expected, like a number-as-string comparison that never matches. By default a variable may be null; setting nullable = false forbids it, and optional() with a default sidesteps the null question entirely for object attributes.

list vs set vs map

list — ordered and allows duplicates, accessed by numeric index. Choose it when order is meaningful (a sequence of CIDR blocks assigned to subnets in order) and you need positional access.

set — unordered and deduplicated, with no stable index. Choose it for a collection of distinct values where order is irrelevant — it avoids the noisy diffs a reordered list produces, and it is the natural source for for_each.

map — key-value pairs accessed by name. Choose it when each element needs a stable identifier (per-environment settings keyed by environment name) rather than a position.

Common Mistakes
  • Typing a structured input as string and parsing it yourself, pushing what should be a plan-time type error into a runtime failure deep in the apply.
  • Using a list where order carries no meaning, getting a destroy/recreate churn every time the order shifts; a set deduplicates and ignores order.
  • Writing an error_message that says "invalid value" without naming the constraint, so the caller still has to read the source to learn what's allowed.
  • Relying on implicit conversion and being bitten when a number compared as a string never equals the number you meant.
  • Leaving a variable nullable when a downstream resource can't accept null, turning a clear input error into a confusing provider rejection.
Best Practices
  • Type variables as precisely as the input deserves; use object with optional() for structured configuration instead of a scatter of loose variables.
  • Add validation blocks for ranges, allowed values, and formats so callers fail fast at plan time with a clear message.
  • Write each error_message to state the constraint and give a valid example, so the caller fixes it without reading the code.
  • Choose list, set, or map by access pattern and diff behavior, not by habit — a set for unordered distinct values avoids reorder churn.
  • Set nullable = false on any variable a downstream resource can't accept null for, catching the omission at the boundary.
Comparable tools CloudFormation limited parameter types with AllowedValues and AllowedPattern Pulumi leans on the host language's own type system Ansible has no real input typing — variables are untyped

Knowledge Check

You have a collection of distinct subnet IDs where order does not matter and you want to feed it to for_each. Which type fits best?

  • A set, because it is unordered and deduplicated, avoiding reorder churn
  • A list, because its positional numeric indexes give each ID the most stable identity
  • A string of comma-separated IDs that you split back apart at the point of use
  • A tuple, because it enforces a fixed length and per-position types

What does a validation block buy you over leaving the variable untyped?

  • A bad value is rejected at plan time with your error message, before any resource changes
  • It encrypts the variable's value at rest in the state file automatically
  • It makes the variable required even when you have not yet removed its declared default value
  • It retries the apply automatically whenever the supplied value is wrong

What does optional(string, "t3.micro") inside an object type do?

  • Lets the caller omit that attribute, substituting the default when they do
  • Forces the attribute to always be set to that one exact constant value
  • Marks the entire object variable itself as optional for the caller
  • Validates that the attribute's value matches the given regular expression pattern

Why does typing an input precisely as an object reduce runtime failures?

  • A shape mismatch is caught as a plan-time type error rather than surfacing as a provider failure mid-apply
  • Object-typed values are applied faster than loose strings because Terraform caches their parsed shape across runs
  • Object-typed variables are automatically marked sensitive and redacted from output
  • Terraform skips the state refresh step entirely for object-typed inputs

You got correct