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
7.3 Complex Types: list, map, object, tuple, set
Quick Answer:
string,number, andboolare primitives.list,set, andmapare collections of one element type.objectandtupleare structural types with a schema. Writelist[0]andmap["key"]. A set is unordered and unique — you cannot index it. Variabletypeconstraints convert compatible values or error. Usetolist,tomap, andtosetto normalize. The exam workhorse islist(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
| Kind | Types | Rule |
|---|---|---|
| Primitive | string, number, bool | One value. Not built from other types. |
| Collection | list(T), set(T), map(T) | Many values of one element type T. |
| Structural | object({ ... }), tuple([ ... ]) | Many values that may have different types, described by a schema. |
| Special | null | Absence. 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:
| Value | Access | Notes |
|---|---|---|
| list / tuple | var.azs[0] | Whole number starting at zero. |
| map / object | var.tags["Environment"] or var.image.id | Bracket 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/bool↔stringwhen 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.
| Function | Does | Exam detail |
|---|---|---|
tolist(x) | Convert to a list | From 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 map | All values must share one type after conversion. tomap({ a = "foo", b = true }) becomes { a = "foo", b = "true" }. |
toset(x) | Convert to a set | Drops 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 forfor_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
listis ordered and indexable.setis neither.mapvalues share one type.objectattributes 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.anyis not a shortcut for "I will figure it out."- Equality does not convert:
true == "true"is false.
A variable is declared as type = set(string). Which statement is true on Terraform 1.12?
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 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?