Skip to main content
RunBook Academy

TerraformIV · HCL: The Terraform Configuration LanguageProduction Terraform

HCL Types and Values

Foundation⏱ ~14 minbash

What you'll learn

  • Name the primitive, collection, and structural types in Terraform and state what they hold
  • Distinguish `list(any)` from `list(string)` and explain why `any` is dangerous in module interfaces
  • Apply type conversion functions (`tonumber`, `tostring`, `tolist`, `toset`, `tomap`) at the right boundary
  • Recognise when Terraform raises a type error from a plan and recover without invalidating state
  • Constrain module inputs with `type` blocks that catch caller mistakes before apply

Prerequisites

None — start here.

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

A type error caught at terraform plan is a five-second fix. The same error caught during apply against a live cloud is a twenty-minute incident. The HCL type system is the boundary that decides which side of that line you land on. Most production TypeErrors in code review come from the same handful of mistakes.

The category of value you are holding

Before discussing any specific type, internalise this: Terraform only knows about values of one of these kinds at runtime.

KindHoldsExamples
PrimitiveA single valuestring, number, bool, null
CollectionA bag of values, all of the same shapelist(...), set(...), map(...)
StructuralA bag of values, each positionally typedtuple([...]), object({...})
SpecialA type that escapes the aboveany

There is no integer type distinct from number. There is no “json” type; a JSON value is just a string that holds JSON. There is no “datetime” type either — see the lesson on expressions for how formatdate and timestamp interact.

Primitives

locals {
  region     = "eu-west-1"   # string
  replicas   = 3             # number
  production = true          # bool
  empty      = null          # null (the only value of type null)
}
  • string is double-quoted. Single quotes are not HCL string delimiters; do not use them.
  • number is decimal. No integer/number distinction. 1 and 1.0 are the same value.
  • bool is true or false. Lower-case. The literal 1 is the number 1, not truthy.
  • null is its own value. It is not equivalent to an empty string "", an empty list [], or zero 0.

Collections

A collection holds any number of values of one consistent element type. The three collections are:

list — ordered, with duplicates

locals {
  azs = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]   # list(string)
}

A list is ordered. [1, 2, 2] and [2, 1, 2] are not equal. Indexing is by integer starting at 0. Lists are the most common collection you will pass around.

set — unordered, no duplicates

locals {
  owners = toset(["platform", "security", "platform"])
  # == toset(["platform", "security"])
}

A set is unordered. Membership is the only operation that is meaningful. Sets are useful when the order you receive from a data source does not matter and you want deduplication for free.

map — keys to values

locals {
  cidr_by_env = {
    prod    = "10.0.0.0/16"
    staging = "10.1.0.0/16"
    dev     = "10.2.0.0/16"
  }
}

A map’s keys must be the same type; in practice they are always strings. Values must be of one declared element type. lookup(map, key, default) lets you handle the “missing key” case without a runtime panic.

Structural types: tuple and object

Structural types let each position have its own type. They are the answer when “all the same shape” is too restrictive.

locals {
  # tuple([string, number, bool]) -- three elements, three types
  env_record = ["prod", 3, true]

  # object({ name=string, replicas=number })
  service = {
    name     = "api"
    replicas = 3
  }
}

In a tuple([...]), element types are declared positionally. In an object({...}), attribute types are declared by name.

Structural types shine in module variable contracts:

variable "services" {
  type = list(object({
    name     = string
    image    = string
    replicas = number
    public   = optional(bool, false)
  }))
}

variable "region" {
  type    = string
  default = "eu-west-1"
}

The caller must supply a list where every entry has exactly those four attributes, in any order, with the right types. terraform plan rejects malformed input before any provider is contacted.

any and what it does (and does not) protect

any is the universal element type. A list(any) accepts any mix of types; an any argument accepts any value. It is convenient for ad-hoc glue and dangerous for module boundaries.

variable "config" {
  type = any   # caller passes anything, module must validate defensively
}

Compare with a constrained interface:

variable "config" {
  type = map(object({
    enabled = bool
    budget  = number
  }))
}

The constrained form catches a typo at terraform plan time with a clear error pointing at the offending attribute. The any form defers the same mistake to whatever code consumes the value — usually a function call that panics on bad input. Use any at the file-local scope for glue computations; never expose it across a module interface.

Type conversion functions

Six conversion functions cover almost every cross-type boundary:

FunctionFromToWhen to call
tostringnumber, boolstringInterpolating into a string that the provider will reject
tonumberstringnumberReading a numeric value from a data source that returns string
tolistset, tuplelistWhen the rest of the configuration expects ordering
tosetlistsetDeduplicating a list
tomapobject, list of 2-tuplesmapProducing a map from a structural value
try/coalesceanysame typeHandling null without an if
locals {
  count_str  = tostring(var.instance_count)        # 3 -> "3"
  count_num  = tonumber("3")                        # "3" -> 3
  deduped    = toset(["a", "b", "a"])               # {"a","b"}
  paired     = tomap({ pair = ["a", "1"] })         # WRONG; tomap wants object({ pairs = list(tuple([string,string])) })
}

Two rules prevent the most common cross-type mistakes:

  1. tonumber fails on non-numeric strings. It is not parseInt. If "abc" reaches tonumber, the whole expression fails and the resource is not created.
  2. tomap accepts an object({...}), not a bare list(...). Either build the right shape first or use zipmap to construct it from two equal-length lists.

Constraining variables: the production pattern

A variable block with a type is the cheapest insurance your module has against caller mistakes:

variable "instance_type" {
  type    = string
  default = "t3.small"

  validation {
    condition     = contains(["t3.small", "t3.medium", "m7i.large"], var.instance_type)
    error_message = "instance_type must be one of the approved sizes; see platform/process/iac.md."
  }
}

variable "replicas" {
  type    = number
  default = 3

  validation {
    condition     = var.replicas >= 1 && var.replicas <= 100
    error_message = "replicas must be between 1 and 100."
  }
}

The validation block runs at terraform plan time. A failed validation aborts the plan with the supplied error_message and the offending input. It is the right place to express business rules that would otherwise only surface during the provider API call.

Type errors: how they look and how to recover

When types mismatch at evaluate time, Terraform produces messages of this shape:

Error: Invalid value for "variable" argument

  on main.tf line 12, in module "network":
  12:   cidr_block = var.cidr

The given value is not of the correct type. Expected a value of type
"string", got "list" with 1 element.

Three diagnostics follow from the message:

  • Which block and attribute. The example names module.network and the cidr_block argument.
  • Expected and got types. String expected, list supplied.
  • The line number. Sometimes accurate, sometimes off by one because of dynamic expansion.

Recover with three commands:

terraform console               # test the offending expression in isolation
terraform validate              # re-check grammar after a fix
terraform plan -refresh=false   # confirm the fix has the intended effect

If the type error comes from a data source, the fix is usually to coerce with tostring, tonumber, or one of the type-conversion functions before passing the value on.

Production failure modes

  1. Returning null instead of an empty collection. A variable default of [] is a list with zero elements. A default of null is the absence of a value. Code that does length(var.items) over a null fails; code over [] returns 0. Pick deliberately.
  2. Passing list(string) where set(string) is expected. Coercion works one way (toset accepts a list). The reverse requires [for s in myset : s] — and you lose ordering.
  3. Mutating var by reference. Variables are immutable; assigning to them is illegal. Compute the variant in locals instead.
  4. Defaulting to any. Stops catching caller mistakes. Replace with a structural type or a primitive + validation block.
  5. Optional attributes without defaults. optional(string) without a default produces null for unspecified entries, which the caller can be surprised by. Prefer optional(string, "") or optional(bool, false) to be explicit.
  6. Converting a map(object) to a list with values() instead of tolist(). tolist() preserves the declared element type; values() widens to any. The widened form passes terraform validate and then explodes in the first function call that expects string attributes.

References

What comes next

The next lesson is Expressions and References: how Terraform evaluates the expression on the right-hand side of an argument, when references are resolved, and why count.index is 0 (not 1).

Verification

terraform validate
terraform console <<< 'var.region'
terraform plan -refresh-only

Expected output:

$ terraform console <<< 'var.region'
"eu-west-1"

If terraform console returns anything other than the typed value, the variable is misconfigured. Fix the type block in variables.tf before re-running the plan.

Knowledge check · 7 questions

  1. Q1. Which type allows duplicates and preserves insertion order?

  2. Q2. What is the difference between `var.items = []` and `var.items = null` in a variable default?

  3. Q3. A `variable` with `type = any` will catch a caller passing a number where a string is expected at `terraform plan` time.

  4. Q4. Which type contract is the right shape for a module variable that should accept a list of named services with `name`, `image`, and `replicas`?

  5. Q5. Which of these are valid Terraform type expressions for a variable block? (Select all that apply.)

  6. Q6. A resource attribute expects `set(string)`, but a `data` source returns `list(string)`. The cleanest fix is?

  7. Q7. A module exposes `variable "config" { type = any }`. A caller passes `{ enabled: "yes" }` (a string, not a bool). When does Terraform first complain?

Passing score: 75%. Answers are checked in this browser.