HCL Type System: Complex Types Deep Dive

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.