Chapter 5: The Language — Variables, Outputs, Expressions
Topic 29

Variable Types and Validation

LanguageTypes

A variable's type is a contract. It rejects a malformed value at plan time instead of letting it reach the Cloud Storage or BigQuery API and fail halfway through an apply. Terraform's type system runs from primitives through collections to structured object({...}) types with optional() fields, and a validation {} block enforces the rules the type system itself cannot express.

On the Hatch pipeline this is the difference between a clear pre-plan error and a cryptic API rejection. A region typed and validated against an allowed list, a label key checked against GCP's format, a bucket-config object validated field by field — each catches the problem where you can read it, not three minutes into an apply that has already created half the resources.

Primitives

The three primitive types are string, number, and bool. Terraform coerces between them where it safely can — the string "3" becomes the number 3 — and errors where it cannot. Typing a variable as bool catches a quoted "true" that would otherwise slip into a resource argument expecting a real boolean and confuse the plan.

Collections

The three collection types differ in ways that matter the moment you iterate over them. A list(string) is an ordered sequence that allows duplicates — the right shape for an ordered set of regions. A set(string) is unordered and unique — the right shape for the service strings you for_each over to enable GCP APIs. A map(string) is key-addressed — the shape for a label bag where each value has a name.

A set for the APIs to enable
variable "enabled_apis" {
  type    = set(string)   # unique, unordered — stable to for_each over
  default = [
    "run.googleapis.com",
    "pubsub.googleapis.com",
    "bigquery.googleapis.com",
  ]
}

The choice between list and set is not cosmetic. for_each over a set keyed by value is stable: adding a service touches only that one element. for_each over a list is positional, so reordering it shifts every key and churns the plan. Picking the wrong one is the most common source of needless destroy-and-recreate churn in the chapter ahead.

Collection types at a glance
list
Ordered, allows duplicates, addressed by index. Reordering churns a for_each; use only for genuinely ordered data.
set
Unordered, unique, addressed by value. Stable to iterate; the right shape for for_each.
map
Key-addressed, each value carries a name you control.

Structured Types

An object({...}) type describes the shape of one structured input, field by field. A "bucket config" variable typed as object({ name = string, location = string, versioning = bool }) validates every field on the way in, so a missing field or a misspelled key fails at plan rather than producing a half-formed resource. The positional cousin tuple([...]) exists but is reached for far less often, because named fields read better than positions.

Optional Object Fields

optional(bool, false) inside an object({...}) marks a field optional and supplies a default when the caller omits it. This is what makes object inputs ergonomic instead of all-or-nothing: a caller of a Hatch ingest module can leave out force_destroy and get false, rather than the type rejecting the whole object for one missing attribute.

An object type with an optional field
variable "raw_bucket" {
  type = object({
    name          = string
    location      = string
    versioning    = optional(bool, true)
    force_destroy = optional(bool, false)   # caller may omit; defaults to false
  })
}

A caller now specifies only what differs from the norm. Without the optional() defaults, every caller would be forced to set versioning and force_destroy on every invocation, and the input would become hostile to use for the common case.

Validation Blocks

A validation { condition = ... error_message = ... } block enforces a rule the type cannot. contains(["us-central1","us-east1"], var.region) pins the region to an allowed list; can(regex("^[a-z][a-z0-9_-]{0,62}$", var.label_key)) rejects a label key GCP would refuse. The check runs at plan and fails with your error message — not as a cryptic API rejection three steps into the apply.

A validation block with a human error message
variable "region" {
  type = string
  validation {
    condition     = contains(["us-central1", "us-east1"], var.region)
    error_message = "region must be us-central1 or us-east1."
  }
}

The condition must return a bool. A pattern check belongs inside can(...) so a regex that fails to match produces false — the case your error_message handles — instead of raising its own error that masks your message entirely.

The nullable Knob

nullable = false forbids passing null to a variable, which is useful when downstream logic assumes a real value — a length(var.x) that would error on null. The default, nullable = true, lets null through, and a passed null behaves differently from an unset variable: unset falls back to the default, while an explicit null is a value Terraform carries forward.

list vs set vs map

list — ordered and allows duplicates, addressed by index. Order-sensitive, so a for_each keyed by index churns when the order shifts. Choose it for genuinely ordered data where position carries meaning.

set — unordered and unique, addressed by value. The right type for for_each over service names, where order is meaningless and a duplicate is a bug. Choose it whenever you iterate by value.

map — key-addressed, each element carrying a stable identity. Choose it when each element needs a name you control — a label bag, or a set of named bucket configs you for_each over by key.

Common Mistakes
  • Typing a "list of regions" as list(string) and for_each-ing over it, then watching every resource get destroyed and recreated when you reorder the list — for_each over a set keyed by value is stable; a list is positional.
  • Writing a validation condition that returns null on bad input instead of false — wrap a regex in can(...) so a non-match produces false, not an error that masks your error_message.
  • Using a bare object({...}) with no optional() fields, forcing every caller to specify every attribute including ones with a sane default — the input becomes hostile to use.
  • Leaving a variable at the default nullable = true, then hitting a downstream length(var.x) that errors on the null you assumed would be an empty value.
  • Typing a structured input as map(any) to avoid spelling out fields, then losing all field-level validation and letting a misspelled key reach the API.
Best Practices
  • Type every variable as specifically as you can — an object({...}) over a loose map(any) — so malformed input fails at plan with a clear path, not at the API mid-apply.
  • Use set(string) for any input you for_each over by value, reserving list for genuinely ordered data where position matters.
  • Add a validation block with a human error_message for any input with a real constraint — allowed regions, label-key format, name length — and wrap pattern checks in can(regex(...)).
  • Give optional object fields a default with optional(field, default) so callers specify only what differs from the norm.
  • Set nullable = false on any variable whose downstream logic assumes a concrete value, so a passed null fails at plan instead of deep in an expression.
Comparable tools Pulumi typed config and input validation in a real language JSON Schema · OpenAPI the validation-contract analog Config Connector CRD field validation Kubernetes admission webhooks, the runtime cousin

Knowledge Check

Why does for_each over a list(string) churn the plan when you reorder it, but a set(string) does not?

  • A list is addressed by position, so reordering shifts every key; a set is keyed by value, so each element is stable regardless of order
  • A set is cached between plans while a list is recomputed from scratch on every run
  • A list can never be passed to for_each, even after converting it with toset
  • Sets have all of their instances applied in parallel during the apply while list instances are applied strictly one after another in order

What does optional(bool, false) do inside an object({...}) type?

  • Marks the field optional and supplies false when the caller omits it, so the object isn't rejected for a missing attribute
  • Pins the field to false permanently, overriding whatever value the caller happens to pass in for that particular attribute
  • Makes the entire enclosing object optional rather than just that one attribute
  • Converts the field to a nullable boolean that falls back to null when omitted

Why does a regex check in a validation block need to be wrapped in can(...)?

  • So a non-matching input produces false — which your error_message handles — instead of raising an error that masks the message
  • can() is mandatory syntax that Terraform always requires wrapped around every single validation condition expression you write
  • It makes the regex evaluate faster when the input string is very large
  • Without it the validation runs at apply time instead of during the plan phase

How does an explicit null differ from an unset variable?

  • An unset variable falls back to its default; an explicit null is a value carried forward, and is rejected when nullable = false
  • They are identical — Terraform treats an explicit null and a completely unset variable as exactly the same default in every case
  • An explicit null always falls back to and quietly triggers the variable's declared default value
  • Unset variables error out at plan while passing null is always perfectly safe

You got correct