TerraformIV · HCL: The Terraform Configuration LanguageProduction Terraform
HCL Types and Values
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
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.
| Kind | Holds | Examples |
|---|---|---|
| Primitive | A single value | string, number, bool, null |
| Collection | A bag of values, all of the same shape | list(...), set(...), map(...) |
| Structural | A bag of values, each positionally typed | tuple([...]), object({...}) |
| Special | A type that escapes the above | any |
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)
}
stringis double-quoted. Single quotes are not HCL string delimiters; do not use them.numberis decimal. No integer/number distinction.1and1.0are the same value.boolistrueorfalse. Lower-case. The literal1is the number1, not truthy.nullis its own value. It is not equivalent to an empty string"", an empty list[], or zero0.
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:
| Function | From | To | When to call |
|---|---|---|---|
tostring | number, bool | string | Interpolating into a string that the provider will reject |
tonumber | string | number | Reading a numeric value from a data source that returns string |
tolist | set, tuple | list | When the rest of the configuration expects ordering |
toset | list | set | Deduplicating a list |
tomap | object, list of 2-tuples | map | Producing a map from a structural value |
try/coalesce | any | same type | Handling 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:
tonumberfails on non-numeric strings. It is notparseInt. If"abc"reachestonumber, the whole expression fails and the resource is not created.tomapaccepts anobject({...}), not a barelist(...). Either build the right shape first or usezipmapto 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.networkand thecidr_blockargument. - 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
- Returning
nullinstead of an empty collection. A variable default of[]is a list with zero elements. A default ofnullis the absence of a value. Code that doeslength(var.items)over anullfails; code over[]returns0. Pick deliberately. - Passing
list(string)whereset(string)is expected. Coercion works one way (tosetaccepts a list). The reverse requires[for s in myset : s]— and you lose ordering. - Mutating
varby reference. Variables are immutable; assigning to them is illegal. Compute the variant inlocalsinstead. - Defaulting to
any. Stops catching caller mistakes. Replace with a structural type or a primitive +validationblock. - Optional attributes without defaults.
optional(string)without a default producesnullfor unspecified entries, which the caller can be surprised by. Preferoptional(string, "")oroptional(bool, false)to be explicit. - Converting a
map(object)to alistwithvalues()instead oftolist().tolist()preserves the declared element type;values()widens toany. The widened form passesterraform validateand then explodes in the first function call that expects string attributes.
References
- HashiCorp, “Type Constraints” — https://developer.hashicorp.com/terraform/language/values/variables#type-constraints
- HashiCorp, “Type Conversion Functions” — https://developer.hashicorp.com/terraform/language/functions/type-conversion-functions
- HashiCorp, “Custom Condition Checks” — https://developer.hashicorp.com/terraform/language/values/variables#custom-condition-checks
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
Q1. Which type allows duplicates and preserves insertion order?
Q2. What is the difference between `var.items = []` and `var.items = null` in a variable default?
Q3. A `variable` with `type = any` will catch a caller passing a number where a string is expected at `terraform plan` time.
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`?
Q5. Which of these are valid Terraform type expressions for a variable block? (Select all that apply.)
Q6. A resource attribute expects `set(string)`, but a `data` source returns `list(string)`. The cleanest fix is?
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.