Terraform

Terraform Variable Types and Consistency: Using string / list / map / object Correctly

2026-04-19
NicheeLab Editorial Team

When input variable types are left ambiguous, module consumers are more likely to pass the wrong values and you end up with unexpected plan diffs. Terraform ships with the HCL2 type system, so you can declare type constraints on the variable block via the type argument. This article focuses on the four most-used types — string, list, map, and object — and walks through the design and implementation patterns for keeping your inputs consistent.

The Terraform Associate exam frequently tests the behavior of each type and how it interacts with surrounding features like validation and for_each. Learning these alongside real-world design principles turns them into reliable points on the exam.

Type Constraint Basics and the 4 Main Types

Terraform input variables are declared with a variable block, and adding a type argument validates user input and tfvars values during plan and apply. If you set a default, the type is inferred from that value implicitly, but you should still specify the type explicitly to make the contract at module boundaries crystal clear.

string holds a single value, list is an ordered sequence of the same type, map is an unordered key-value dictionary, and object is a struct-like type with a fixed attribute schema. object is great for locking down a module API contract, map is useful for accepting dictionaries with arbitrary keys, and list shines for inputs where order matters (for example, CIDR priority order).

any is flexible but weakens validation and lets bad input slip through, so avoid it outside of short-lived migration scenarios.

  • An explicit type is the contract between the module provider and its consumers — it reduces future breaking changes
  • list preserves order, map does not (which affects how diffs appear)
  • object locks in both attribute names and types, giving you very strict inputs
  • Add range checks with variable.validation to enforce consistency that the type system alone cannot
TypeTypical literal exampleMain use caseGotchas
string"prod"Environment names, regions, identifiersCombine with validation to check for empty strings and allowed characters
list(string)["web", "api"]Ordered entry lists (e.g., security rules)Cannot be used directly with for_each (convert to map or set)
map(string){ env = "prod", team = "platform" }Tags / labels, arbitrary-key dictionariesUnordered. Designs that lean on lookup and merge are the standard
object({ name=string, size=string }){ name = "app", size = "t3.small" }Locking down module input/output contractsAll attributes are required (missing ones cause errors). Mind backward compatibility when extending
list(object({...}))[{ name="a" }, { name="b" }]Array of same-shaped objects (driving multiple resources)In practice, keying by something like name and converting to a map for for_each is the stable approach

Type constraint and validation flow (input → type check → module)

tfvars / -varenvironment varsvariable.type(string/list/...)validation block(range / format checks)module inputs(resource args)input → variable.type → validation → module inputs

Declaration examples for the 4 main types (with type constraints)

variable "env" {
  type        = string
  description = "Deployment environment (dev/stg/prod)"
  validation {
    condition     = contains(["dev", "stg", "prod"], var.env)
    error_message = "env must be one of dev, stg, or prod."
  }
}

variable "subnets" {
  type        = list(string)
  description = "Ordered list of subnet IDs"
}

variable "tags" {
  type        = map(string)
  description = "Common tags"
  default     = {}
}

variable "server" {
  type = object({
    name = string
    size = string
    tags = map(string)
  })
  description = "Single server spec"
}

Real-World string Patterns and Pitfalls

string is the simplest type, but lax control over naming rules and allowed values leads to running into cloud-side naming constraints or mixing up environments. The standard practice is to combine it with validation that uses ranges or regular expressions.

If you need multi-line strings you can use a heredoc (<<-EOT ... EOT), but instead of feeding long text through input variables, you'll usually find template files or locals-based construction easier to maintain.

  • Restrict environment and service names to safe characters with a regular expression
  • Add a length check that matches cloud-side limits (e.g., 63 characters)
  • sensitive = true only suppresses output — types and value ranges still need separate validation

A safe string variable definition (regex + range)

variable "env" {
  type        = string
  description = "Environment name (dev|stg|prod)"
  validation {
    condition     = can(regex("^(dev|stg|prod)
NicheeLab を読み込み中…
quot;, var.env)) error_message = "env must be one of dev, stg, or prod." } } variable "service_name" { type = string description = "Service name (lowercase alphanumerics and hyphens, 1..30 chars)" validation { condition = can(regex("^[a-z0-9-]{1,30}
NicheeLab を読み込み中…
quot;, var.service_name)) error_message = "service_name must use only lowercase alphanumerics and hyphens (max 30 chars)." } }

list Consistency: list(string) and list(object)

A list is an ordered sequence where the order carries meaning. The type constraint list(T) applies T to every element. list(string) fits ID lists, while list(object) lets you express a set of entries that share the same schema.

A resource's for_each is limited to map or set, so you can't use list(object) directly with for_each. In practice, converting it into a map keyed by a unique attribute like name via a comprehension before for_each is the stable approach. When order matters and you use count, it's also important to design around index-dependent reorders that would cause destructive changes.

  • When order matters, design a keying strategy to avoid unintended replacements during edits
  • for_each only takes map/set; convert a list with { for v in var.list : key(v) ⇒ v }
  • list vs tuple: tuples can have a different type per element (recognizing the term is enough for the Associate exam)

Converting list(object) to a map for for_each

variable "servers" {
  description = "Servers to deploy"
  type = list(object({
    name = string
    size = string
    tags = map(string)
  }))
}

locals {
  servers_by_name = { for s in var.servers : s.name => s }
}

# Example: provider-agnostic pseudo resource
resource "example_server" "this" {
  for_each = local.servers_by_name
  name     = each.value.name
  size     = each.value.size
  tags     = each.value.tags
}

map Key Consistency and Default Design

map(string) is the go-to type for tags and labels. Order isn't guaranteed, so the order shown in plan output can shift. To stay consistent, default the map to {} and compose shared and override tags using merge — it's the easiest pattern to work with.

For potentially missing key lookups, use lookup(map, key, default) for safety. Constraining key formats (allowed characters) with validation lets you get ahead of cloud-side restrictions.

  • Allow callers to omit the argument with default = {}
  • Watch the order in merge(var.tags, local.common_tags) — later arguments win
  • Validate key format with regex; validate allowed value sets with contains/alltrue

Composing and safely accessing map(string)

variable "tags" {
  type        = map(string)
  description = "Common tags (overridable by callers)"
  default     = {}
  validation {
    condition     = alltrue([for k in keys(var.tags) : can(regex("^[-a-z0-9]+
NicheeLab を読み込み中…
quot;, k))]) error_message = "Tag keys must contain only lowercase alphanumerics and hyphens." } } locals { base_tags = { "managed-by" = "terraform" } final_tags = merge(local.base_tags, var.tags) } output "team_tag" { value = lookup(local.final_tags, "team", "unknown") }

Designing object Types: Locking Down Schemas and Evolving Them

object fixes both attribute names and types, which makes it a powerful contract at module boundaries. Every attribute is required, and missing one causes a type-mismatch error. When you add attributes later, plan for backward compatibility (existing consumers' tfvars must keep working) by splitting the new field into a separate variable or providing an extension hook via map(string).

You can use null as the default for an entire object, but the consumer has to handle null with coalesce or conditional logic to fill in defaults. To make individual attributes nullable, it's easier to enforce consistency by ruling out null at design time and providing explicit defaults instead.

  • Use object when you need a strict contract; provide an extension hook via map when you need flexibility
  • Introduce new attributes incrementally with compatibility in mind (don't break existing tfvars)
  • Add validation to enforce attribute-level consistency (e.g., the format of name)

Designing an object variable with validation

variable "server" {
  description = "Server spec (strict schema)"
  type = object({
    name = string
    size = string
    tags = map(string)
  })
  validation {
    condition     = can(regex("^[a-z0-9-]+
NicheeLab を読み込み中…
quot;, var.server.name)) && length(var.server.name) <= 30 error_message = "server.name must use lowercase alphanumerics and hyphens, max 30 characters." } } locals { server_tags = merge({ "managed-by" = "terraform" }, var.server.tags) } # Example usage (output) output "server_label" { value = "${var.server.name}:${var.server.size}" }

Consistency Strategies and Associate Exam Tips

In practice, sticking to the basics — explicit variable types, validation to constrain value ranges, list converted to map before for_each, map composed and safely accessed with merge/lookup, and object used to lock down contracts — eliminates a lot of confusion. Normalizing inputs with the type conversion functions toset/tomap/tolist is also effective.

The exam frequently tests differences between list and map (order, for_each compatibility), the strictness of object, the pitfalls of any, and the role of validation. Also make sure you understand the input paths via tfvars and -var/-var-file, the effect of default, and the fact that type errors are caught during plan.

  • Always specify the type explicitly — avoid any by default
  • Convert list to a map before for_each (with stable, unique keys)
  • Default map to {} and remember that merge resolves with later arguments winning
  • Lean on validation for regex and inclusion checks
  • Catch type and value errors early with terraform validate and plan

Input paths and normalization snippet (tfvars and type conversion)

# terraform.tfvars example
env     = "stg"
subnets = ["subnet-aaa", "subnet-bbb"]

# CLI example
# terraform plan -var-file="env/stg.tfvars"

# Example of type normalization (list → map)
locals {
  items = ["a", "b", "c"]
  items_map = { for v in local.items : v => upper(v) }
}

Check Your Understanding

Associate

問題 1

A module accepts the following input variable. Which tfvars definition matches the tags type constraint? variable "tags" { type = map(string) description = "Common tags" }

  1. A. tags = { env = "prod", team = "platform" }
  2. B. tags = ["env", "prod"]
  3. C. tags = { env = ["prod"] }
  4. D. tags = "env=prod"

正解: A

map(string) is a dictionary whose keys and values are both strings. A matches the key:string → value:string shape. B is a list, C has a list for the value, and D is a single string — none of those satisfy the constraint.

Frequently Asked Questions

When should I use map vs object?

Use map(string) when you want to accept a dictionary with arbitrary keys (e.g., tags). Use object when you want to lock down the attribute names and types as a strict contract. If you anticipate future expansion, a hybrid that uses object for required fields and map for optional extensions also works well.

Why can't I use a list with for_each, and how do I work around it?

for_each requires a map or a set. A list allows ordering and duplicates, so it has no stable keys. The workaround is to convert it into a map with a comprehension: { for v in var.list : key(v) => v }.

How do I handle type mismatch errors?

Compare the variable's type with the actual values in tfvars or defaults, and normalize them with toset/tomap/tolist. Check whether you've accidentally passed a list to a map(string), whether any required attributes are missing from your object, and review your validation conditions.

Check what you learned with practice questions

Practice with certification-focused question sets

無料で問題を解いてみる
Author

NicheeLab Editorial Team

NicheeLab editorial team focused on data engineering and cloud certification learning. Content is structured around practical study needs and official exam domains.


Related articles
Terraform

HCL Syntax: Terraform's Configuration Language (2026)

HCL2 fundamentals for Terraform — blocks, attributes, expres...

Terraform

Terraform Authoring & Operations Pro: Complete Guide (2026)

Tactics for the Terraform Pro exam — module authoring, works...

Terraform

Terraform Providers: Plugin Management Fundamentals (2026)

Provider mechanics — required_providers, versions, mirrors, ...

Terraform

Terraform Resource Blocks: Declarative Infra Units (2026)

Resource block fundamentals — addresses, references, common ...

Terraform

Terraform Data Sources: Read-Only External Data (2026)

Data source basics — declaration, refresh behavior, dependen...

Browse all Terraform articles (102)
© 2026 NicheeLab All rights reserved.