OnCallReady

Lesson 12.8 · Terraform: Language & Workflow · 29 min read

Types, validation and custom conditions

In plain words

Picture a shape-sorter toy: a round hole, a square hole, a star hole. Try to push a star through the round hole and it simply will not go in, right at the start, before anything else happens. A good shape sorter also has a grown-up checking the pieces: "yes it's round, but it's too big".

Terraform's type on a variable is the shape sorter: string, number, list(string), map(object({ cidr = string })). A value of the wrong shape is converted if it safely can be, otherwise rejected at the door with the variable's name. validation blocks are the grown-up checking values. Preconditions, postconditions and check blocks extend the same idea to resources and to things that drift over time.

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:

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.

│ The given value is not suitable for var.subnets declared at
│ variables.tf:1,1-19: element "app": attribute "cidr" is required.

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."
  }
}

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

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:

Why it helps

Without types, a caller's typo surfaces three resources later as a baffling Azure API error mid-apply, when half the environment already exists. With map(object(...)) and validation, it becomes a clear plan-time error naming the attribute. When you write reusable code for other teams (modules, Ch 13), this is how you make it hard to misuse. In review you will catch the silent trap: extra object attributes are dropped, so a caller's servce_endpoints typo gives no error at all. Exam objective 4g tests exactly when to use validation vs precondition vs postcondition vs check, and interviewers like "how do you stop a bad value reaching prod?"

Commands in this lesson

terraform

FAQ

What is the difference between a list, a set and a tuple?

A list(T) is ordered, indexable, all elements the same type. A set(T) holds unique values with no order and no index, so s[0] is an error. A tuple([T1, T2]) is fixed length where each position has its own type. A literal ["a", "b"] is technically a tuple until a type constraint or tolist / toset converts it. for_each wants a set of strings or a map, never a list.

What does optional() do in an object type?

optional(T, default) (Terraform 1.3+) lets a caller leave an attribute out of an object, and fills in the default. Without a default, an omitted optional attribute is null. It is what makes map(object({ cidr = string, nsg = optional(bool, true) })) pleasant to call: most subnets pass only cidr. Remember that unknown extra attributes are silently dropped, not rejected.

When should I use validation, precondition, postcondition or a check block?

Validation, inside a variable, rejects bad input. A precondition, in a resource's lifecycle or an output, enforces an assumption before the resource is planned. A postcondition verifies the result through self, for example that a looked-up resource group is in westeurope. A check block monitors something that can drift (a certificate expiry) and only warns, never blocks. The first three fail the run; check blocks never do.

Can a validation condition refer to other variables?

Since Terraform 1.9, yes: condition = var.max_nodes >= var.min_nodes works, and conditions may even use data sources. Before 1.9 a validation could only reference its own variable, and cross-variable rules had to be preconditions. If a module must support older Terraform, keep cross-variable rules in preconditions.

What does nullable = false change?

By default a caller may pass null explicitly, and the variable then really 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. It protects module defaults when callers pass through values that may be null, such as sku = var.maybe_sku.

In an interview Mid

How do you make a Terraform configuration reject bad input before it creates anything?

Three layers, earliest first:

A check block is different: it warns but never fails the run - for monitoring things that can drift, like a certificate's expiry.

Also asked: What is the difference between a list, a set and a map in Terraform? · What does nullable = false do on a variable? · When would you use a check block instead of a precondition?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.