Why types matter
What you need to know already: variables, var.<name> and validation (12.5), environment variables (6.1), CIDR notation (8.3).
Without a type, a variable accepts anything, and the mistake shows up three resources later as a baffling provider error - or not at all. With a type, Terraform converts what it can and rejects the rest at the door, with the variable's name in the message.
The type system
A type is the kind of value: text, a number, a list... Terraform's types:
primitive string number bool
collection list(T) set(T) map(T) every element has the SAME type T
structural object({ name = T, ... }) named attributes, each its own type
tuple([T1, T2, ...]) fixed length, each position its own type
any "work it out from the value" a placeholder, not a type
null the absence of a value; valid for any type
(T stands for "some type", e.g. list(string) = a list of strings. A collection holds any number of same-typed elements; a structural type has a fixed shape, like a TypeScript interface.)
The collection/structural split is the one to understand:
list(string)- any number of strings, in order.["a", "b", "c"].set(string)- unique strings, no order and no index.toset(["b", "a"])is["a", "b"], ands[0]is an error.map(number)- any keys, every value a number.{ web = 2, api = 3 }.object({ name = string, size = number })- exactly these attributes, each with its own type.tuple([string, number])- exactly two elements, a string then a number.
A literal ["a", "b"] is a tuple and { a = 1 } is an object until a type constraint or a function turns it into a list or a map. You can see the difference in the console - terraform console opens an interactive prompt (>) where you type an expression and see its value, like the browser's JS console; exit or Ctrl+D leaves. It prints the type in the output:
$ terraform console
> ["a", "b"]
[
"a",
"b",
]
> tolist(["a", "b"])
tolist([
"a",
"b",
])
> toset(["b", "a", "b"])
toset([
"a",
"b",
])
> { web = 2 }
{
"web" = 2
}
> tomap({ web = 2 })
tomap({
"web" = 2
})
> type({ web = 2, name = "x" })
object({
name: string,
web: number,
})
(type() exists only in the console - it is a debugging aid.)
Conversion
Terraform converts automatically between primitives when it is safe:
"5" -> number 5 only if the string is a valid number
5 -> string "5"
"true" -> bool true only "true" and "false"
true -> string "true"
That is why TF_VAR_node_count=3 works for a number variable: environment variables are always strings, and "3" converts. It is also why this fails:
# the types mission's configuration (node_count is a number)
terraform plan -var node_count=three
╷
│ Error: Invalid value for input variable
│
│ on variables.tf line 12:
│ 12: variable "node_count" {
│
│ The given value is not suitable for var.node_count declared at
│ variables.tf:12,1-22: a number is required.
╵
Tuples and objects convert to lists and maps when every element fits the element type. A list of mixed strings and numbers converts to list(string) (numbers become strings); an object with one numeric and one string attribute cannot become map(number).
object types for structured input
The most useful constraint in real configurations: a map of objects, one per thing to create. Here, one per subnet - a slice of a network's address range (8.6). In Azure, subnets live inside a virtual network (VNet), your private network in the cloud.
variable "subnets" {
description = "Subnets to create, keyed by name."
type = map(object({
cidr = string
service_endpoints = optional(list(string), [])
nsg = optional(bool, true)
}))
}
# terraform.tfvars
subnets = {
app = { cidr = "10.20.1.0/24" }
data = { cidr = "10.20.2.0/24", service_endpoints = ["Microsoft.Storage"] }
mgmt = { cidr = "10.20.9.0/26", nsg = false }
}
The tfvars file gives three subnets. service_endpoints is an Azure setting (a private path from the subnet to an Azure service - here, storage); nsg says whether to attach a network security group, Azure's firewall rule list for a subnet.
optional(T, default)(Terraform 1.3+) lets callers leave an attribute out; the default fills in. Without a default an omitted optional attribute isnull.- A required attribute that is missing is caught by name:
│ The given value is not suitable for var.subnets declared at
│ variables.tf:1,1-19: element "app": attribute "cidr" is required.
- The trap: extra attributes are silently dropped. A caller who writes
servce_endpoints(typo) gets no error - the attribute just is not there, and the default applies. Validation can catch it if it matters.
Validation rules
Types say what shape a value has; validation says which values are acceptable.
variable "subnets" {
type = map(object({ cidr = string }))
validation {
condition = alltrue([for s in values(var.subnets) : can(cidrhost(s.cidr, 0))])
error_message = "Every subnet cidr must be a valid IPv4 CIDR, like 10.20.1.0/24."
}
validation {
condition = length(var.subnets) <= 10
error_message = "At most 10 subnets per VNet in this design."
}
}
- Several
validationblocks may sit in one variable; each is checked. can(expr)istrueif the expression evaluates without an error - the standard way to turn "would this function fail?" into a boolean.values(map)gives the map's values as a list;[for s in ... : ...]builds a list from each element (12.11);alltrue(list)is true if every element is.- Since Terraform 1.9 a condition may refer to other variables and even to data sources (12.18):
condition = var.max_nodes >= var.min_nodes. Older versions only allowed the variable itself. - Validation runs during
validate,planandapply- before any resource is touched.
nullable
A SKU ("stock-keeping unit") is cloud-speak for a product tier or size, e.g. Standard vs Premium.
variable "sku" {
type = string
default = "Standard"
nullable = false
}
By default a caller may pass null explicitly, and the variable then is null
- the default does not apply. With
nullable = false, passing null makes
Terraform use the default instead (and a variable without a default rejects null). Modules use it so that sku = var.maybe_null_sku in a caller does not knock out a safe default.
Custom conditions: preconditions, postconditions, checks
Validation checks inputs. But some rules are about how things fit together ("prod needs at least 3 machines"). Exam objective 4g. Four tools, different moments:
validation inside a variable checks an INPUT blocks the plan
precondition in lifecycle / output checks an assumption BEFORE blocks the plan
postcondition in lifecycle checks the RESULT, via self blocks (after apply if
the value is unknown)
check block top level (1.5+) continuous assertion WARNS, never blocks
lifecycle { } is a nested block any resource may have, holding settings for how Terraform treats that resource (more of it in Ch 13).
A precondition documents and enforces an assumption the resource depends on. Here the resource is a VM scale set - a group of identical virtual machines (rented servers) that Azure runs for you:
resource "azurerm_linux_virtual_machine_scale_set" "web" {
# ...
instances = var.node_count
lifecycle {
precondition {
condition = var.node_count >= 3 || var.env != "prod"
error_message = "Production needs at least 3 machines."
}
}
}
╷
│ Error: Resource precondition failed
│
│ on main.tf line 31, in resource "azurerm_linux_virtual_machine_scale_set" "web":
│ 31: condition = var.node_count >= 3 || var.env != "prod"
│
│ Production needs at least 3 machines.
╵
A postcondition checks what the resource ended up as, through self (the object being checked). A data block reads something that already exists (12.18):
data "azurerm_resource_group" "shared" {
name = "rg-shared-network"
lifecycle {
postcondition {
condition = self.location == "westeurope"
error_message = "The shared network must be in westeurope."
}
}
}
An output precondition guards what a module promises to its callers:
output "subnet_id" {
value = azurerm_subnet.app.id
precondition {
condition = length(var.subnets) > 0
error_message = "The module was called without any subnets."
}
}
A check block is for "this should be true, and tell me if it stops being true" - it never fails the run. This one reads a TLS certificate (9.15) stored in a Key Vault and warns when it expires within 720 hours (30 days):
check "certificate_not_expiring" {
data "azurerm_key_vault_certificate" "tls" {
name = "api-tls"
key_vault_id = azurerm_key_vault.main.id
}
assert {
condition = timecmp(data.azurerm_key_vault_certificate.tls.expires, timeadd(timestamp(), "720h")) > 0
error_message = "The API certificate expires within 30 days."
}
}
╷
│ Warning: Check block assertion failed
│
│ on main.tf line 60, in check "certificate_not_expiring":
│ 60: condition = timecmp(...) > 0
│
│ The API certificate expires within 30 days.
╵
The data source nested in a check block is scoped to it: nothing outside can reference it, and if it fails to read, that is a warning too. HCP Terraform (HashiCorp's hosted service for running Terraform, Ch 14) can run check blocks on a schedule, which is where the "continuous validation" name comes from.
Choosing between them: reject bad input with validation; protect an assumption a resource relies on with a precondition; verify a result with a postcondition; monitor something that can drift over time with a check.
How the pieces fail, in order
When something is wrong, Terraform catches it at one of these stages, earliest first:
1. HCL syntax terraform validate / any command
2. types of input variables "Invalid value for input variable"
3. validation blocks "Invalid value for variable"
4. references and functions "Unsupported attribute", "Invalid index", ...
5. preconditions "Resource precondition failed"
6. the provider API errors during apply
7. postconditions "Resource postcondition failed"
8. check blocks warnings, always last
The earlier a mistake is caught, the cheaper it is. Every rule you move up this list is an incident that becomes a failed plan instead.
What you can now do:
- Pick a type constraint (
list,set,map,objectwithoptional) for an input. - Predict what converts and what is rejected.
- Choose between validation, precondition, postcondition and check.