Study guide

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

HashiCorp Certified: Terraform Associate 004

Track your progress as you read

Finished sections get a check mark.

1 Introduction & Exam Overview

1.1 Current Exam Facts 1.2 Format, Delivery, Retakes, and Recertification 1.3 004 Changes, Terraform 1.12, and Study Plan

2 Infrastructure as Code with Terraform

2.1 What Infrastructure as Code Is 2.2 Advantages of IaC Patterns 2.3 Multi-Cloud, Hybrid, and Service-Agnostic Workflows

3 Terraform Fundamentals: Providers & State

3.1 Install and Version Terraform Providers 3.2 How Terraform Uses Providers 3.3 Configuration with Multiple Providers 3.4 How Terraform Uses and Manages State

4 Core Terraform Workflow: Init & Validate

4.1 The Terraform Write–Plan–Apply Workflow 4.2 Initialize a Working Directory 4.3 Validate a Configuration

5 Plan, Apply, Destroy, and Formatting

5.1 Generate and Review an Execution Plan 5.2 Apply Changes to Infrastructure 5.3 Destroy Terraform-Managed Infrastructure 5.4 Formatting and Style Adjustments

6 Resources, Data Sources, and References

6.1 Resource Blocks vs Data Blocks 6.2 Resource Attributes and Cross-Resource References 6.3 Resource Addressing and Interpolation Patterns

7 Variables, Outputs, and Complex Types

7.1 Input Variables, tfvars, and Precedence 7.2 Output Values 7.3 Complex Types: list, map, object, tuple, set

8 Dynamic Configuration and Dependencies

8.1 Expressions, count, for_each, and dynamic Blocks 8.2 Built-in Functions 8.3 Dependencies, depends_on, and Lifecycle Rules

9 Conditions, Secrets, and Sensitive Data

9.1 Custom Conditions: validation, precondition, postcondition, check 9.2 Sensitive Data Handling 9.3 Vault, Ephemeral Values, and Write-Only Arguments

10 Terraform Modules

10.1 How Terraform Sources Modules 10.2 Variable Scope within Modules 10.3 Using Modules in Configuration 10.4 Managing Module Versions

11 Terraform State Management

11.1 The Local Backend 11.2 State Locking 11.3 Remote State and the backend Block 11.4 Resource Drift and State Operations

12 Maintain Infrastructure with Terraform

12.1 Import Existing Infrastructure 12.2 Inspect State with the CLI 12.3 Verbose Logging and Diagnostics

13 HCP Terraform

13.1 Create Infrastructure with HCP Terraform 13.2 Collaboration and Governance Features 13.3 Workspaces and Projects 13.4 HCP Terraform Integrations

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

Key Takeaways Explain This Topic AI

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

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 ↔ string 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.

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 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.