Skip to content
Infrastructure as Code iac hcl 4 min read

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 — either true or false.
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, and bool when it is unambiguous — the string "3" becomes the number 3, and true becomes "true". Relying on this is fine, but declaring an explicit type makes 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.

TypeDescriptionAccess by
list(<TYPE>)Ordered sequence, duplicates allowedinteger index
set(<TYPE>)Unordered collection, no duplicates(no index)
map(<TYPE>)Unordered key/value pairs, string keysstring 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 type on variables; it documents intent and turns silent coercion bugs into early validation errors.
  • Prefer concrete constraints like list(object({...})) over any so Terraform validates caller input.
  • Use set instead of list when order is irrelevant and duplicates are meaningless, especially for for_each.
  • Reach for optional() with defaults in object types rather than requiring callers to fill every attribute.
  • Use null to mean “use the default,” and reserve "" and [] for genuinely empty values.
  • Convert explicitly with tonumber/tostring/toset at boundaries instead of relying on implicit conversion in complex expressions.
Last updated June 14, 2026
Was this helpful?