Variable Types and Validation
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.
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.
for_each; use only for genuinely ordered data.for_each.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.
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.
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 — 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.
- Typing a "list of regions" as
list(string)andfor_each-ing over it, then watching every resource get destroyed and recreated when you reorder the list —for_eachover asetkeyed by value is stable; a list is positional. - Writing a
validationcondition that returnsnullon bad input instead offalse— wrap a regex incan(...)so a non-match producesfalse, not an error that masks yourerror_message. - Using a bare
object({...})with nooptional()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 downstreamlength(var.x)that errors on thenullyou 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.
- Type every variable as specifically as you can — an
object({...})over a loosemap(any)— so malformed input fails at plan with a clear path, not at the API mid-apply. - Use
set(string)for any input youfor_eachover by value, reservinglistfor genuinely ordered data where position matters. - Add a
validationblock with a humanerror_messagefor any input with a real constraint — allowed regions, label-key format, name length — and wrap pattern checks incan(regex(...)). - Give optional object fields a default with
optional(field, default)so callers specify only what differs from the norm. - Set
nullable = falseon any variable whose downstream logic assumes a concrete value, so a passednullfails at plan instead of deep in an expression.
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 withtoset - 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
falsewhen the caller omits it, so the object isn't rejected for a missing attribute - Pins the field to
falsepermanently, 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
nullwhen omitted
Why does a regex check in a validation block need to be wrapped in can(...)?
- So a non-matching input produces
false— which yourerror_messagehandles — 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
nullis a value carried forward, and is rejected whennullable = false - They are identical — Terraform treats an explicit
nulland a completely unset variable as exactly the same default in every case - An explicit
nullalways falls back to and quietly triggers the variable's declared default value - Unset variables error out at plan while passing
nullis always perfectly safe
You got correct