HCL Type System: Complex Types Deep Dive
Why Types Matter in Infrastructure Code
A lot of Terraform configs run fine for months — until someone passes a list where a map is expected, or a module consumer can’t figure out what format a variable needs, or a value silently gets coerced to a type that changes its meaning. These bugs are subtle, hard to diagnose, and entirely preventable with explicit types.
HCL’s type system isn’t just a nice-to-have. It’s the contract between your module and its consumers. When you declare a variable as type = object({ name = string, port = number }), anyone calling your module knows exactly what shape the input must be. Terraform validates it at plan time. No guessing. No runtime surprises.
Primitive Types — The Foundation
The three building blocks that every complex type is composed of:
variable "instance_type" {
type = string
default = "t3.micro"
}
variable "instance_count" {
type = number
default = 2
}
variable "enable_monitoring" {
type = bool
default = true
}
string, number, bool. These should be second nature. Every complex type — lists, maps, objects — is built from these primitives.
One subtlety: HCL performs automatic type coercion in some cases. "42" can be coerced to the number 42. "true" can become the boolean true. This is convenient but can mask bugs — you might pass a string where a number is expected and it silently works, until you pass "forty-two" and it doesn’t.
list(TYPE) — Ordered Collection
An ordered sequence of values, all the same type. Order matters. Position matters.
variable "availability_zones" {
type = list(string)
default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
variable "allowed_ports" {
type = list(number)
default = [80, 443, 8080]
}
Access by index (0-based):
local.availability_zones[0] # "us-east-1a"
Use with count to create multiple resources:
resource "aws_subnet" "public" {
count = length(var.availability_zones)
availability_zone = var.availability_zones[count.index]
...
}
The critical gotcha with lists and count: order matters in state. If you have three subnets created from a list ["us-east-1a", "us-east-1b", "us-east-1c"] and you remove the middle element, the subnets at index 1 and 2 get reassigned. Terraform doesn’t see “remove the b subnet.” It sees “index 1 changed from b to c, and index 2 disappeared.” This can cause unexpected destroys and recreates.
This is the #1 argument for using for_each with a set or map instead of count with a list — more on that in the count vs for_each article.
map(TYPE) — Key-Value Lookup
A collection of key-value pairs where all values are the same type. The keys are always strings.
variable "instance_types" {
type = map(string)
default = {
dev = "t3.micro"
staging = "t3.medium"
prod = "t3.xlarge"
}
}
Access by key:
instance_type = var.instance_types[var.environment]
# or with a fallback default
instance_type = lookup(var.instance_types, var.environment, "t3.micro")
lookup(map, key, default) returns the default value if the key doesn’t exist. This is safer than direct bracket access (map["key"]), which errors if the key is missing. Use lookup when the key might not be present; use bracket notation when you’re certain it is.
Maps are the natural choice for environment-specific configuration. Instead of writing conditionals everywhere (var.environment == "prod" ? "t3.xlarge" : "t3.micro"), you define the mapping once and do a lookup. Cleaner, more maintainable, and easier to extend — adding a new environment means adding one line to the map, not updating every conditional.
set(TYPE) — Unordered, Unique Values
Like a list, but without ordering guarantees and with uniqueness enforcement. Terraform internally sorts the values.
variable "allowed_cidrs" {
type = set(string)
default = ["10.0.0.0/8", "192.168.0.0/16"]
}
Sets work perfectly with for_each because the key is the value itself — stable, meaningful, and independent of insertion order. This is why for_each requires a map or set, not a list — sets provide stable identity.
You can’t access set elements by index (they have no stable index). If you need positional access, convert to a list with tolist(). But if you’re reaching for positional access, you probably want a list, not a set.
object({...}) — Structured Records
Named attributes with potentially different types. This is HCL’s equivalent of a struct, a dataclass, or a typed dictionary.
variable "database" {
type = object({
name = string
port = number
username = string
multi_az = bool
})
default = {
name = "appdb"
port = 5432
username = "admin"
multi_az = false
}
}
Access attributes with dot notation:
port = var.database.port
username = var.database.username
Why use object({}) instead of separate variables? Compare:
# Without objects — four separate variables, no grouping
variable "db_name" { type = string }
variable "db_port" { type = number }
variable "db_username" { type = string }
variable "db_multi_az" { type = bool }
# With objects — one variable, clear structure
variable "database" { type = object({ name = string, port = number, username = string, multi_az = bool }) }
The object version communicates intent: these values belong together. They describe a single logical thing (a database). When a module has 20 separate variables that are really 4 groups of 5, refactoring them into objects dramatically improves the module’s usability.
Objects also enforce structure. If the caller omits port, Terraform errors at plan time: "The argument port is required." The module’s interface is self-documenting and enforced.
tuple([TYPE, TYPE, ...]) — Fixed-Length Mixed Sequences
Like a list, but each position can have a different type. Fixed length.
variable "app_config" {
type = tuple([string, number, bool])
default = ["web", 8080, true]
}
Tuples are rarely used in practice. They’re awkward to consume because the meaning of each position is implicit — what does index 0 mean? What’s index 2? An object({name = string, port = number, enabled = bool}) is almost always more readable because the field names carry meaning.
any — The Escape Hatch
variable "config" {
type = any
}
Terraform accepts any type without validation. Use this when you genuinely need polymorphism — like a module wrapper that passes arbitrary configuration through to another module. Avoid it everywhere else, because it removes the type safety that catches misconfiguration at plan time.
Complex Nested Types — Production Module Interfaces
Types can be nested arbitrarily deep. This is how you build rich, typed interfaces for modules:
variable "subnets" {
type = list(object({
name = string
cidr = string
az = string
public = bool
}))
default = [
{ name = "pub-1", cidr = "10.0.1.0/24", az = "us-east-1a", public = true },
{ name = "pub-2", cidr = "10.0.2.0/24", az = "us-east-1b", public = true },
{ name = "prv-1", cidr = "10.0.3.0/24", az = "us-east-1a", public = false },
]
}
Use with for_each (converting the list to a map keyed by name):
resource "aws_subnet" "this" {
for_each = { for s in var.subnets : s.name => s }
vpc_id = aws_vpc.main.id
cidr_block = each.value.cidr
availability_zone = each.value.az
map_public_ip_on_launch = each.value.public
}
This is the pattern for production-grade modules. One variable captures the full configuration for all subnets. The for expression transforms the list into a map keyed by name, giving each subnet a stable identity for for_each. Adding a new subnet means adding one entry to the caller’s list — no code changes in the module.
map(TYPE) vs object({}) — When to Use Which
This distinction confuses people. The rule:
map(string)— Use when all values are the same type and the keys are dynamic (you don’t know them in advance). Tags, environment mappings, arbitrary key-value configs.object({})— Use when you have a known, fixed structure with named fields of potentially different types. Database config, service definitions, things with a schema.
# map — dynamic keys, same value type
variable "tags" {
type = map(string)
}
# Can be: { "Project" = "web", "CostCenter" = "12345" }
# object — fixed schema, mixed types
variable "database" {
type = object({ name = string, port = number, multi_az = bool })
}
# Must have exactly: name, port, and multi_az
Type Conversion Functions
When types don’t match, you can convert explicitly:
tostring(42) # "42"
tonumber("42") # 42
tobool("true") # true
tolist(toset(["a"])) # Convert set to list
toset(["a", "a", "b"]) # Set (removes duplicate "a")
tomap({a = 1}) # Explicit map
The most common real-world use: toset() to convert a list to a set for for_each, since for_each doesn’t accept lists directly.
Key Takeaways
Being explicit with types is the difference between a module that’s self-documenting and one that requires reading the source code to understand. type = object({...}) in your module variables creates an enforced contract — the caller knows exactly what to provide, and Terraform validates it at plan time.
The list vs set decision matters for for_each. Lists have ordering; sets have uniqueness and stable identity. If ordering doesn’t matter (and it usually doesn’t for infrastructure resources), use set to avoid the reindexing trap.
map(string) is for dynamic key-value pairs where you don’t know the keys in advance. object({}) is for known structures with named fields. Using the wrong one doesn’t cause errors, but it changes how well Terraform can validate inputs and how clearly your module communicates its interface.

