DevOps Peach

Advanced Terraform · Modules and Code Structure · Lesson 4 of 5

Types, Expressions and Validation

Typed variables with optional attributes, locals and for expressions keep code short; validation blocks and pre/postconditions turn bad input into clear errors.

Practice interactively →

Terraform has a small, strict type system. Use it so that mistakes show up as a clear error at plan, not as a half-built environment.

flowchart LR
  V["variable apps (typed)"] --> VAL["validation"]
  VAL --> LO["locals + for expressions"]
  LO --> PRE["precondition"]
  PRE --> RES["resource"]
  RES --> POST["postcondition"]
  %% step V: A type constraint such as map(object({...})) rejects input of the wrong shape.
  %% step VAL: validation blocks check the values themselves and print your error message.
  %% step LO: locals and for expressions reshape input into exactly what resources need.
  %% step PRE: A precondition checks assumptions before Terraform creates or changes the resource.
  %% step POST: A postcondition checks the result after the resource or data source is read.

Types and optional attributes

variable "apps" {
  type = map(object({
    image    = string
    port     = number
    replicas = optional(number, 1)       # default when the caller omits it
    env      = optional(map(string), {})
  }))
}

Primitive types: string, number, bool. Collections: list(...), set(...), map(...). Structural: object({...}), tuple([...]). Mark secrets with sensitive = true (module 5) and use nullable = false when null must not be passed.

validation

variable "environment" {
  type = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be dev, staging or prod."
  }
}

Since Terraform 1.9 a validation condition may also refer to other variables.

locals and for expressions

locals {
  name_prefix = "shop-${var.environment}"
  public_apps = { for k, a in var.apps : k => a if a.port < 9000 }
  all_ports   = [for a in values(var.apps) : a.port]
  tags        = merge(var.common_tags, { env = var.environment })
}

[for ...] builds a list, {for k, v in ... : k => v} builds a map, and if filters. Other everyday tools: conditional cond ? a : b, splat docker_container.app[*].name, try(), coalesce(), lookup(), flatten().

Pre- and postconditions

resource "docker_container" "web" {
  # ...
  lifecycle {
    precondition {
      condition     = var.environment != "prod" || var.replicas >= 2
      error_message = "prod needs at least 2 replicas."
    }
    postcondition {
      condition     = self.exit_code == null
      error_message = "the container exited right after start."
    }
  }
}
Check Runs Blocks
Type constraint Before anything Wrong shape of input
validation On the variable Bad input value
precondition Before create/update Broken assumption
postcondition After create/read Unexpected result
check block (module 3) Every plan/apply Nothing: it only warns

Traps: map(any) hides typos that object() would catch; and a failing precondition stops the run, while a failing check only prints a warning.

Practice interactively →