Types & Values
Every value in HCL has a type, and that type determines how Terraform validates input, converts data, and reports errors. The type system spans simple primitives, homogeneous collections, and richer structural types that mix different fields together. Knowing how these types behave — and how Terraform converts between them — is essential for writing variables, outputs, and module interfaces that fail fast instead of producing confusing runtime errors. This applies equally to Terraform 1.5+ and OpenTofu, which share the same HCL2 type system.
Primitive types
There are three primitive types. They are the atoms from which every other type is built.
string— a sequence of Unicode characters, written in double quotes.number— a numeric value that covers both integers and fractions; Terraform stores all numbers with arbitrary precision internally.bool— eithertrueorfalse.
variable "cluster_name" {
type = string
default = "prod-cluster"
}
variable "desired_capacity" {
type = number
default = 3
}
variable "enable_logging" {
type = bool
default = true
}
Tip: Terraform automatically converts between
string,number, andboolwhen it is unambiguous — the string"3"becomes the number3, andtruebecomes"true". Relying on this is fine, but declaring an explicittypemakes the conversion intentional and catches genuine mistakes.
Collection types
Collection types group multiple values of the same element type. You declare them with a type constructor that names the element type in angle brackets.
| Type | Description | Access by |
|---|---|---|
list(<TYPE>) | Ordered sequence, duplicates allowed | integer index |
set(<TYPE>) | Unordered collection, no duplicates | (no index) |
map(<TYPE>) | Unordered key/value pairs, string keys | string key |
variable "availability_zones" {
type = list(string)
default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
variable "allowed_ports" {
type = set(number)
default = [22, 80, 443]
}
variable "instance_tags" {
type = map(string)
default = {
Environment = "production"
Team = "platform"
}
}
Lists are indexed with var.availability_zones[0], and maps are keyed with var.instance_tags["Environment"]. Sets have no order and no index, so you typically iterate them with a for_each meta-argument or a for expression rather than addressing elements directly.
Structural types
Structural types also group multiple values, but unlike collections they allow different types in different positions.
object({ ... })— a collection of named attributes, each with its own type. Think of it as a typed struct.tuple([ ... ])— an ordered sequence where each position has its own declared type.
variable "database" {
type = object({
engine = string
instance_class = string
allocated_gb = number
multi_az = bool
})
default = {
engine = "postgres"
instance_class = "db.t3.medium"
allocated_gb = 100
multi_az = true
}
}
variable "ingress_rule" {
type = tuple([number, number, string])
default = [443, 443, "0.0.0.0/0"]
}
Object attributes can be marked optional with the optional() modifier, which is the idiomatic way to give module callers sensible defaults without forcing them to specify every field.
variable "bucket_config" {
type = object({
name = string
versioning = optional(bool, false)
encryption = optional(string, "AES256")
})
}
If a caller omits versioning, Terraform substitutes false; omitting encryption yields "AES256".
The null value
null represents the absence of a value. Assigning null to a resource argument tells Terraform to use the provider’s default or to leave the argument unset, exactly as if you had not written it. It is distinct from an empty string "" or an empty list [].
resource "aws_instance" "app" {
ami = "ami-0c02fb55956c7d316"
instance_type = "t3.micro"
# Omit the key pair entirely when none is provided
key_name = var.key_name != "" ? var.key_name : null
}
Type conversion
Terraform converts values automatically when the conversion is safe and unambiguous. The dynamic, untyped collections list, map, and set of any also convert to and from structural types where the shapes line up. You can force a conversion explicitly with the tostring, tonumber, tobool, tolist, toset, and tomap functions.
locals {
port_string = tostring(443) # "443"
unique_azs = toset(["a", "b", "a"]) # set with two elements
retries = tonumber("5") # 5
}
When a conversion is impossible — converting "hello" to a number, for instance — Terraform stops with a clear error during plan.
Output:
│ Error: Invalid function argument
│
│ on locals.tf line 3, in locals:
│ 3: retries = tonumber("hello")
│
│ Invalid value for "v" parameter: cannot convert "hello" to number.
Complex type constraints and any
You can nest type constructors to describe arbitrarily complex shapes, such as a list of objects representing subnets:
variable "subnets" {
type = list(object({
cidr_block = string
public = bool
}))
}
The special keyword any is a placeholder that matches any type. Terraform unifies the actual element types at apply time. Use it sparingly — list(any) accepts more inputs but gives up the validation a concrete constraint provides.
Best Practices
- Always declare an explicit
typeon variables; it documents intent and turns silent coercion bugs into early validation errors. - Prefer concrete constraints like
list(object({...}))overanyso Terraform validates caller input. - Use
setinstead oflistwhen order is irrelevant and duplicates are meaningless, especially forfor_each. - Reach for
optional()with defaults in object types rather than requiring callers to fill every attribute. - Use
nullto mean “use the default,” and reserve""and[]for genuinely empty values. - Convert explicitly with
tonumber/tostring/tosetat boundaries instead of relying on implicit conversion in complex expressions.