7.3 Complex Types: list, map, object, tuple, set

Key Takeaways

  • Primitives are string, number, and bool; collections are list, set, and map of one element type; structural types are object and tuple with a schema of potentially different types
  • Index a list or tuple with [0]; index a map with ["key"] or .key; sets are unordered unique collections and cannot be indexed until you convert them
  • Put type constraints on variables so Terraform converts compatible values or raises a type-mismatch error before plan finishes
  • tolist, tomap, and toset normalize values — toset drops duplicates and order; converting a set of strings to a list sorts them lexicographically
  • list(object({ ... })) is the usual constraint for a typed list of records such as port mappings or subnet definitions
Last updated: August 2026

7.3 Complex Types: list, map, object, tuple, set

Quick Answer: string, number, and bool are primitives. list, set, and map are collections of one element type. object and tuple are structural types with a schema. Write list[0] and map["key"]. A set is unordered and unique — you cannot index it. Variable type constraints convert compatible values or error. Use tolist, tomap, and toset to normalize. The exam workhorse is list(object({ ... })).

Objective 4d on Terraform Associate (004) is: understand and use complex types. Official pages: Types and Values and Type Constraints. Type constraints in Terraform 1.12 are valid on the type argument of an input variable.

Three layers of types

KindTypesRule
Primitivestring, number, boolOne value. Not built from other types.
Collectionlist(T), set(T), map(T)Many values of one element type T.
Structuralobject({ ... }), tuple([ ... ])Many values that may have different types, described by a schema.
SpecialnullAbsence. Setting a resource argument to null is the same as omitting it.

list without an argument is shorthand for list(any). Same for map. New configuration should write the full constructor: list(string), map(number).

There is also any, a placeholder, not a type. HashiCorp's warning is blunt: do not use any just to skip a constraint. The only honest use is passing an opaque blob straight into something like jsonencode without reading fields.

Collections versus structural types

list(T) — ordered sequence, indexes start at 0. Duplicates allowed. var.azs[0] is the first availability zone.

set(T) — unique values, no order, no index. You cannot write var.azs[0] when azs is a set(string). Convert with tolist first. Sets are the natural type for for_each over a collection of unique names.

map(T) — values keyed by a string. All values share type T. var.tags["Environment"] or var.tags.Environment if the key is a valid identifier. Prefer brackets when keys come from users.

object({ attr = TYPE, ... }) — named attributes, each with its own type. A matching value must include every required attribute. Extra attributes are discarded during conversion (lossy if you later convert back to a map).

tuple([TYPE, TYPE, ...]) — fixed length, each position has its own type. tuple([string, number, bool]) matches ["a", 15, true] and rejects a four-element list.

HCL literals look like lists and maps even when the physical type is a tuple or object. ["us-west-1a", "us-west-1c"] is a tuple of strings until a constraint converts it to list(string). { name = "web", count = 3 } is an object until a map(...) constraint converts it — and that conversion fails if the values cannot share one type.

variable "azs" {
  type    = list(string)
  default = ["us-west-2a", "us-west-2b"]
}

variable "tags" {
  type = map(string)
  default = {
    Project     = "billing"
    Environment = "prod"
  }
}

variable "image" {
  type = object({
    id    = string
    arch  = string
    hvm   = bool
  })
}

Indexing and attributes:

ValueAccessNotes
list / tuplevar.azs[0]Whole number starting at zero.
map / objectvar.tags["Environment"] or var.image.idBracket form is safer for arbitrary keys.
set(none)Convert: tolist(var.az_set)[0]. String sets become lexicographically ordered lists. Other element types have no guaranteed order.

Type constraints, conversion, and mismatch errors

Terraform converts automatically when the kinds are similar:

  • number / boolstring when the string is a valid representation (15"15", true"true").
  • list ↔ tuple when the length works (a list converts to a tuple only if it has exactly the required number of elements).
  • map ↔ object when the map has at least the object's keys; extras are dropped.
  • list/tuple → set drops duplicates and order. set → list of strings sorts lexicographically.

Automatic conversion does not run for the equality operator. 1 == "1" is false.

A type-mismatch error means no conversion path exists. Official example: a map(string) cannot accept { name = ["Kristy", "Claudia"], age = 12 } because a tuple cannot become a string.

variable "names" {
  type = list(string)
}
# Assigned ["a", 15, true] becomes ["a", "15", "true"] — conversion succeeds.

variable "labels" {
  type = map(string)
}
# Assigned { name = ["a", "b"], age = 12 } — type mismatch, plan fails.

Write the constraint on the variable, not as a hope that callers will guess. default must itself convert to type.

tolist, tomap, toset

Explicit conversion is rarely required because Terraform converts at assignment. HashiCorp says to use these functions mainly to normalize module outputs. On 004 they also appear when you must index a set or force uniqueness.

FunctionDoesExam detail
tolist(x)Convert to a listFrom a set: order is undefined for a given type except that string sets become lexicographic lists, stable within one run. Mixed elements become the most general type (tolist(["a", "b", 3])"3" as a string).
tomap(x)Convert to a mapAll values must share one type after conversion. tomap({ a = "foo", b = true }) becomes { a = "foo", b = "true" }.
toset(x)Convert to a setDrops duplicates and order. toset(["c", "b", "b"]) is { "b", "c" }. Mixed types generalize the same way.
variable "example_set" {
  type    = set(string)
  default = ["foo", "bar"]
}

locals {
  example_list = tolist(var.example_set)
}

output "first_element" {
  value = local.example_list[0]
}

Do not write var.example_set[0]. That is a 4d fail.

When to use each type

  • string / number / bool — one scalar. Region, count, a feature flag.
  • list — ordered, positional, duplicates allowed. AZ preference order, a sequence you will index.
  • set — membership only. Unique names for for_each. You do not care about position.
  • map — same-typed values keyed by string. Tags, a lookup table of instance types.
  • object — one record with named fields of mixed types. A single image descriptor.
  • tuple — a fixed positional record ([name, port, enabled]). Rare in new modules; prefer an object so callers use names instead of positions.
  • list(object({...})) — many records. Subnets, listener rules, container ports.

Worked example: list(object({...}))

HashiCorp's own variable block example is the one to recognize on sight:

variable "docker_ports" {
  type = list(object({
    internal = number
    external = number
    protocol = string
  }))
  description = "Port mappings for the container."
  default = [
    {
      internal = 8300
      external = 8300
      protocol = "tcp"
    }
  ]
}

resource "example_container" "app" {
  # each object is one mapping
  dynamic "ports" {
    for_each = var.docker_ports
    content {
      internal = ports.value.internal
      external = ports.value.external
      protocol = ports.value.protocol
    }
  }
}

Each element must supply internal, external, and protocol with those types. An extra key on an assigned object is dropped during conversion. Missing protocol is a type error. var.docker_ports[0].external is 8300. If you had used list(map(string)) you would lose number types and you could not require those three keys.

A sibling pattern on 004 is a list of subnet objects:

variable "private_subnets" {
  type = list(object({
    cidr = string
    az   = string
    tags = map(string)
  }))
}

That constraint rejects a bare list of CIDR strings and rejects an object that forgot az. That is the point of 4d: the type is the module's contract.

004 traps for complex types

  • list is ordered and indexable. set is neither.
  • map values share one type. object attributes can differ.
  • Extra object keys disappear on conversion; do not rely on them later.
  • tolist(set_of_strings) is sorted. tolist(set_of_objects) is not a stable business order.
  • any is not a shortcut for "I will figure it out."
  • Equality does not convert: true == "true" is false.
Test Your Knowledge

A variable is declared as type = set(string). Which statement is true on Terraform 1.12?

A
B
C
D
Test Your Knowledge

How do you read the first CIDR in a list(string) variable named cidrs and the Environment tag in a map(string) variable named tags?

A
B
C
D
Test Your Knowledge

A module declares variable "docker_ports" { type = list(object({ internal = number, external = number, protocol = string })) }. A caller assigns [{ internal = 80, external = 8080, protocol = "tcp", extra = true }]. What happens?

A
B
C
D