Background

HCL and Types

35 min read

HCL looks like a configuration format and behaves like a small language with a real type system. Most of what feels arbitrary about Terraform at intermediate level comes from two facts on this page: a .tf file is evaluated rather than executed, and a set is not a list. This page is the grammar, the types, and the conversions Terraform performs silently on your behalf.

Prerequisites: Anatomy of a Project


What & Why

HCL — HashiCorp Configuration Language — is the language Terraform configuration is written in. It has three structural ideas: blocks that declare things, arguments that set values inside them, and expressions that compute those values. On top of that sits a type system with primitives, collections and structural types, and a set of automatic conversions between them.

The bad practice it replaces

JSON and YAML, and specifically what they can't do. Neither has comments worth the name, neither has expressions, and neither can express "this value comes from that other thing" — so infrastructure templates written in them accumulate a templating layer on top, which is how the world got Helm's Go templates and CloudFormation's !Sub and Fn::Join. Those exist because the underlying format cannot compute, and text-templating a structured document is a famously bad idea: the templating layer doesn't know it's producing YAML, so indentation bugs become runtime failures.

HCL's bet is to put the expressions inside the language, where they can be type-checked, rather than on top of it as string manipulation. That's the whole justification for a bespoke language, and it's mostly borne out.

Where it sits

Everything else in the article is written in this. The page deliberately stops before the things HCL is usually taught alongside: variable blocks and their validation are Variables, Locals & Outputs, for expressions and functions are Expressions & Functions, and the resource block gets Resources & References. Here it's grammar and types only.

Three things it's confused with

HCL is not JSON with nicer syntax. There is a JSON representation of the same schema — .tf.json — and it exists for machine generation. But HCL has expressions, and expressions are not representable as data; the JSON form embeds them in strings.

HCL is not YAML. No significant whitespace, so indentation is cosmetic. Nothing you get wrong about indentation will change behaviour, which is a larger relief than it sounds.

HCL is not a general-purpose language. No loops, no functions you can define, no imports, no mutable variables, no I/O. locals are named expressions, not assignments — you can't reassign one. Every attempt to write imperative logic in HCL produces something worse than the declarative version, and the temptation is strongest around dynamic blocks (Expressions & Functions).

When NOT to lean on it

  • Don't fight the type system with strings. If you find yourself building JSON with join and string concatenation, use jsonencode over a real object — it can't produce malformed output, and it type-checks.
  • Don't use any to avoid declaring a type. It compiles and it defers every error to the point of use, where the message will name a nested attribute rather than the thing you got wrong.
  • Don't reach for .tf.json by hand. It's for generators. Written by a person it's unreadable and the expressions end up as strings.
  • Don't encode logic in HCL that belongs elsewhere. Deeply nested conditionals producing different resource shapes are a sign the configuration wants splitting into modules or separate configurations, not more cleverness.

Core Concepts

HCLthe configuration language. HashiCorp Configuration Language, version 2. Files use the .tf extension. A JSON-equivalent syntax exists in .tf.json for machine generation.

Blocka declaration. A block type, zero or more labels, and a body in braces:

resource "aws_s3_bucket" "demo" {   # type "resource", labels "aws_s3_bucket" and "demo"
  bucket = "example"                # body
}

The number of labels is fixed per block type: resource takes two, variable and output take one, locals and terraform take none.

Argumenta named value inside a block. name = expression. Note the =.

Attributea value you can read from something. Frequently the same word as argument and worth keeping distinct: you set arguments, you read attributes. Some attributes are computed by the provider and can only be read. See Resources & References.

Nested blocka block inside a block, with no =. versioning { enabled = true }. Whether a given piece of provider schema is a nested block or an argument taking an object is a decision the provider made, and it determines the syntax you must use.

Expressionanything that produces a value. A literal, a reference, an operator application, a function call. Expressions & Functions.

Identifiera name. Letters, digits, underscores and hyphens; cannot begin with a digit. Convention is snake_case, and hyphens in resource names are legal but awkward because they need quoting in some positions.

Primitive typea single value. string, number, bool.

Collection typemany values of one type. list(T), set(T), map(T). The element type is part of the type, so list(string) and list(number) are different types.

Structural typemany values of different types. object({name = string, size = number}) and tuple([string, number, bool]). Each position or attribute has its own type.

list(T)ordered, indexable, duplicates allowed. ["a", "b", "a"]. Access by integer index.

set(T)unordered, unique, not indexable. No index access at all — toset(["a"])[0] is an error. Duplicates are silently collapsed.

map(T)string keys to values of one type. { env = "dev", region = "eu" }. Keys are always strings.

object({...})fixed named attributes with individual types. The right type for structured input. Attributes can be marked optional().

tuple([...])fixed-length, position-typed sequence. Rarely written by hand; it's what a literal like ["a", 1] actually is before conversion.

anya placeholder, not a type. Terraform infers a concrete type at the point of use. Legal, and it converts type errors from clear ones into confusing ones.

nullabsent. Explicitly means "this argument was not set", so the provider applies its default. Distinct from "" and 0, both of which are values you chose.

Type conversionautomatic coercion where unambiguous. Terraform converts between compatible types on assignment: "5" to 5, a tuple to a list, an object to a map, a list to a set.

Type constraintthe declared expectation. What you write in a variable's type or an object type's attributes. Terraform checks values against it and converts where it can.

Evaluationworking out values, in dependency order. Not execution. A .tf file has no line one, and declaration order is irrelevant.


How It Works

A .tf file is evaluated, not executed

This is the single most useful thing on the page, because a surprising amount follows from it.

Comparison of sequential top-to-bottom execution against evaluation in dependency order

# This is legal. There is no "before".
output "bucket_name" {
  value = local.full_name
}

locals {
  full_name = "${local.prefix}-data"
}

locals {
  prefix = "tf-demo"
}

Terraform reads the whole configuration, builds a graph of what depends on what, and evaluates in that order. So:

  • Declaration order is irrelevant, within a file and between files.
  • You cannot reassign anything. A second locals block defining prefix again is an error, not an override. There are no variables in the imperative sense — only names bound to expressions.
  • A cycle is an error, not an infinite loop. locals { a = local.b, b = local.a } fails at parse time. Same machinery as The Dependency Graph.
  • "Where do I put this?" has no functional answer, only a conventional one — which is why the main.tf / variables.tf split is convention rather than requirement.

Blocks, arguments, and the distinction that trips people

resource "aws_s3_bucket" "demo" {
  bucket = "tf-hcl-demo-0725"        # argument: scalar

  tags = {                           # argument: a map value, note the =
    Environment = "dev"
  }
}

resource "aws_s3_bucket_versioning" "demo" {
  bucket = aws_s3_bucket.demo.id

  versioning_configuration {         # nested block: no =
    status = "Enabled"
  }
}

tags = { ... } and versioning_configuration { ... } look almost identical and are different things. The first is an argument whose value happens to be a map; the second is a nested block. Which one a given piece of provider schema is, is fixed by the provider, and getting it wrong produces an error that reads oddly:

# UNVERIFIED — confirm against a real run
Error: Unsupported argument

  An argument named "versioning_configuration" is not expected here.
  Did you mean to define a block of the same name?

The practical rule: if the provider documentation shows it with an =, it takes a value; if without, it's a block. This distinction also decides whether you can build the thing dynamically — arguments can be computed by an expression, whereas repeated blocks need dynamic, which is Expressions & Functions.

Strings

locals {
  simple      = "plain"
  interpolated = "prefix-${var.environment}-suffix"
  literal_dollar = "costs $${100}"        # $$ escapes interpolation

  # Heredoc. <<- strips the leading indentation.
  policy_note = <<-EOT
    This bucket is managed by Terraform.
    Environment: ${var.environment}
  EOT
}

Two habits worth forming now. Don't interpolate a single value"${var.name}" should just be var.name; terraform fmt won't fix it and reviewers notice. And don't build JSON with heredocs; use jsonencode over an object, which cannot produce malformed JSON and gives you type checking for free.

The type system

Diagram of the HCL type system showing primitive, collection and structural types with the automatic conversions between them

Type Written Example value Ordered Duplicates Indexable
string string "dev"
number number 3, 1.5
bool bool true
list list(string) ["a", "b", "a"] Yes Yes by integer
set set(string) ["a", "b"] No No No
map map(string) { env = "dev" } No keys unique by key
object object({n = string}) { n = "x" } No attrs unique by attribute
tuple tuple([string, number]) ["a", 1] Yes Yes by integer

Collections are homogeneous; structural types are not. list(string) says every element is a string. tuple([string, number]) says there are exactly two elements and they have those types. Similarly map(string) says every value is a string, while object({...}) names each attribute and types it individually. In practice: use object for structured input, map for arbitrary key–value data like tags.

set versus list — the distinction that matters later

They look the same when written, because a set has no literal syntax of its own — you write a list and convert it. The differences:

Comparison of a list as ordered indexed slots against a set as an unordered collection of unique values with a duplicate discarded

locals {
  as_list = ["b", "a", "b"]           # tuple → list(string): 3 elements, ordered
  as_set  = toset(["b", "a", "b"])    # set(string): 2 elements, unordered
}
  • Sets de-duplicate silently. Three elements in, two out, no warning.
  • Sets have no order. Not "an order you shouldn't rely on" — no order. Terraform renders them sorted for display, which misleads people into thinking they're sorted.
  • Sets cannot be indexed. local.as_set[0] is an error. Convert with tolist() first, accepting that you're choosing an arbitrary order.

Why care? Because the distinction becomes load-bearing when a collection determines resource addresses — a set's elements can be used as identities, and a list's positions cannot safely be. That argument belongs to Meta-Arguments, and it's the reason this page bothers to be precise about a difference that looks academic.

Type conversion

Terraform converts automatically where there's exactly one sensible answer:

From To Result
"5" number 5
5 string "5"
"true" bool true
true string "true"
tuple list(T) if every element converts
list set(T) de-duplicated, order lost
object map(T) if every attribute converts
anything any unchanged; type inferred later

And refuses where there isn't:

# UNVERIFIED — confirm against a real run
Error: Invalid value for input variable

  a number is required

Two consequences worth internalising. A literal ["a", "b"] is a tuple, not a list — it becomes a list when assigned somewhere expecting one. And conversion is one-way at the point of assignment; a variable declared set(string) given ["a", "a"] holds one element forever, and nothing later recovers the original.

null, and why it isn't empty string

resource "aws_s3_bucket" "demo" {
  bucket = "tf-hcl-demo-0725"

  # null means "don't set this argument" — the provider's default applies.
  # "" would mean "set it to the empty string", which is usually an error.
  object_lock_enabled = var.enable_lock ? true : null
}

This is the idiom for conditionally setting an argument, and it's better than the alternatives because it leaves no trace in the plan when unset. null in a collection is a real element with the value null, which is a different matter and a common source of confusion when building lists conditionally.

Types meeting provider schemas — where the clouds diverge

The type system is Terraform's and identical everywhere. What differs is the constraints providers put on values of those types, and metadata is the clearest case: all three take a map(string), and what they accept in it is not the same.

```hcl
locals {
  common_tags = {
    Environment = "dev"          # mixed case keys and values are fine
    CostCentre  = "CC-1234"
    ManagedBy   = "Terraform"
  }
}

resource "aws_s3_bucket" "demo" {
  bucket = "tf-hcl-demo-0725"    # lowercase, hyphens allowed, 3–63 chars
  tags   = local.common_tags
}
```
`tags` is a free-form `map(string)`. Keys and values are case-sensitive and
accept a broad character set.
```hcl
locals {
  common_tags = {
    Environment = "dev"          # mixed case keys and values are fine
    CostCentre  = "CC-1234"
    ManagedBy   = "Terraform"
  }
}

resource "azurerm_storage_account" "demo" {
  # 3–24 chars, lowercase alphanumeric only — so the string must be
  # transformed, not just interpolated.
  name                     = replace(lower("tfhcldemo0725"), "-", "")
  resource_group_name      = azurerm_resource_group.demo.name
  location                 = azurerm_resource_group.demo.location
  account_tier             = "Standard"
  account_replication_type = "LRS"
  tags                     = local.common_tags
}
```
Same `map(string)` for `tags`. The naming constraint is the type-system-adjacent
trap: a name assembled from a prefix and an environment will contain hyphens,
which are invalid, so Azure configurations need string transformation where the
others need interpolation.
```hcl
locals {
  # Labels: lowercase keys AND values, restricted charset. The same map
  # used for AWS and Azure tags will be rejected here.
  common_labels = {
    environment = "dev"
    cost_centre = "cc-1234"      # underscores, not hyphens; lowercase
    managed_by  = "terraform"
  }
}

resource "google_storage_bucket" "demo" {
  name     = "tf-hcl-demo-0725"
  location = "EUROPE-WEST1"
  labels   = local.common_labels
}
```
`labels`, not `tags`, and the value constraint is real: uppercase is rejected.
A shared tagging local therefore needs a transformation for GCP rather than
being passed through — typically a `for` expression lowercasing both sides.

The general point: the type checker will not save you from a provider's value constraints. All three arguments above are map(string), all three would pass terraform validate, and the GCP one fails at apply if you hand it the AWS map. That gap between "correctly typed" and "acceptable to the provider" is why tflint exists (Testing & Validation) and why the shared-tagging-module problem is harder than it looks. The canonical comparison is in Providers & the Registry.


Getting Started

The type system is the one part of Terraform you can explore without a cloud account, using terraform console — an interactive evaluator. Start there, then do one small apply.

Explore types with no infrastructure

mkdir tf-hcl-demo && cd tf-hcl-demo
echo 'terraform { required_version = ">= 1.5" }' > versions.tf
terraform init
terraform console
# UNVERIFIED — confirm against a real run
> type("hello")
string

> type(3)
number

> type(["a", "b"])
tuple([string, string])

> type(tolist(["a", "b"]))
list(string)

> type({ env = "dev", size = 3 })
object({ env = string, size = number })

> toset(["b", "a", "b"])
toset([
  "a",
  "b",
])

> length(toset(["b", "a", "b"]))
2

> toset(["a", "b"])[0]
Error: Invalid index — this value does not have any indices.

> tostring(5)
"5"

> tonumber("5")
5

> tonumber("five")
Error: Invalid function argument

> 3 == "3"
true

> null == ""
false

Four results are the lesson. ["a", "b"] is a tuple, not a list, until something converts it. toset collapsed three elements to two silently. A set cannot be indexed at all. And 3 == "3" is true, because Terraform converts before comparing — which is convenient and occasionally surprising.

Exit with exit or Ctrl-D.

Then one apply, to see types meet a provider

# main.tf
terraform {
  required_version = ">= 1.5"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
}

provider "aws" {
  region = "eu-west-1"
}

locals {
  environment = "dev"

  # An object: named attributes, individually typed.
  config = {
    versioning = true
    retention  = 30
  }

  # A map(string): what tags wants. Note the number becomes a string.
  common_tags = {
    Environment = local.environment
    Retention   = local.config.retention
  }
}

resource "aws_s3_bucket" "demo" {
  bucket = "tf-hcl-demo-0725"
  tags   = local.common_tags
}
terraform init && terraform plan
# UNVERIFIED — confirm against a real run
  # aws_s3_bucket.demo will be created
  + resource "aws_s3_bucket" "demo" {
      + bucket = "tf-hcl-demo-0725"
      + tags   = {
          + "Environment" = "dev"
          + "Retention"   = "30"
        }
    }

Retention was the number 30 in the local and is the string "30" in the plan. Nothing warned you; tags is map(string), so Terraform converted. This is the type system doing its job, and it's also how a value you expected to be numeric quietly becomes text.

Try breaking it — set Retention = local.config and re-plan:

# UNVERIFIED — confirm against a real run
Error: Incorrect attribute value type

  Inappropriate value for attribute "tags": element "Retention": string required.

The error names the map key, which is what a good type error should do.

terraform apply
terraform destroy

In Practice

Declare every type explicitly. The production form of any input is a named type, and any is a decision to receive worse error messages later:

variable "buckets" {
  description = "Buckets to create, keyed by logical name."

  type = map(object({
    versioning_enabled = bool
    retention_days     = optional(number, 30)   # optional with a default, 1.3+
    tags               = optional(map(string), {})
  }))
}

optional() with a default is the feature that makes object types usable for real input — before it, every caller had to supply every attribute, which pushed people to any and map(string) with stringly-typed values. Detail in Variables, Locals & Outputs.

Use jsonencode, never string-built JSON. Policy documents are the common case:

# Good: type-checked, cannot produce malformed JSON.
policy = jsonencode({
  Version = "2012-10-17"
  Statement = [{
    Effect    = "Deny"
    Principal = "*"
    Action    = "s3:*"
    Resource  = "${aws_s3_bucket.demo.arn}/*"
  }]
})

The heredoc version of that works right up until an interpolated value contains a quote.

Normalise metadata at the boundary, not everywhere. Since GCP labels won't accept what AWS and Azure tags will, the maintainable pattern is one canonical map plus one transformation, rather than two hand-maintained maps that drift:

locals {
  common_tags = {
    Environment = var.environment
    CostCentre  = var.cost_centre
    ManagedBy   = "Terraform"
  }

  # GCP-safe: lowercase keys and values, hyphens to underscores.
  common_labels = {
    for k, v in local.common_tags :
    lower(replace(k, "-", "_")) => lower(replace(v, "-", "_"))
  }
}

That for expression is Expressions & Functions; it's here because the type system is what makes the transformation safe rather than hopeful.

Prefer set when membership is the point, list when order genuinely matters. Order matters surprisingly rarely in infrastructure — subnet CIDR allocation and priority-ordered rules are the real cases. Everywhere else, a list invites someone to reorder it, and reordering something Terraform treats positionally is how the disaster in Meta-Arguments happens. If you don't need order, say so in the type.

What a reviewer should look for in a diff touching this topic:

  • type = any, or a missing type. Ask what the real type is. This is the highest-value comment on the list.
  • map(string) holding things that aren't strings — numbers and booleans stringified because the type was convenient. Usually wants object.
  • A list where the elements are named things. Should it be a set, or a map keyed by name? This is a design question with consequences two stages later.
  • Reordering an existing list. Harmless in isolation, and not harmless if anything derives identity from position.
  • String-built JSON or YAML. Ask for jsonencode / yamlencode.
  • "${var.x}" where var.x would do, and heredocs used for single-line strings. Cosmetic, but cheap to fix and it signals whether the author knows the language.
  • "" used where null is meant. Empty string is a value; null means unset.

Blast radius. Type changes are usually inert — but two aren't. Changing a variable's type from list to set de-duplicates and loses order, which can change resource addresses if anything derives identity from the collection. And changing a map key changes any address derived from it. Both show up as replacement in a plan, which is the argument for reading plans rather than diffs.


Ecosystem

terraform console. An interactive evaluator against the current configuration and state. The fastest way to answer "what type is this actually" and "what does this expression return" without an apply. The glue: nothing — run it in the working directory. Use it before writing any non-trivial expression.

terraform fmt. Canonical formatting: alignment, indentation, spacing. It does not change semantics and it does not fix redundant interpolation. Run it via a pre-commit hook so formatting never appears in a review.

terraform validate. Type-checks the configuration against provider schemas without credentials. It catches wrong types and missing required arguments; it cannot catch values a provider will reject, which is the gap in the tab set above.

tflint. Provider-aware linting: invalid values, deprecated arguments, naming constraints — precisely the class validate misses. The glue is a .tflint.hcl and a CI step. Testing & Validation.

Editor tooling — the Terraform Language Server. Completion, hover documentation and inline type errors from provider schemas. The single biggest quality-of-life improvement available while learning the type system, because it surfaces what a schema expects at the moment you type it. ⚠️ verify current extension names before recommending specific editor plugins.

Provider-specific note. The type system doesn't vary by provider. What varies is the value constraints on identically-typed arguments — the metadata case in the tab set, and naming rules — which is why a correctly-typed configuration can still fail at apply on one cloud and not another.


Production

Security

Indirect but real. jsonencode over an object rather than a string-built policy document is a security control, not a style preference: string concatenation with interpolated values is how a malformed or over-permissive IAM policy gets generated, and a malformed policy sometimes fails open. The other consideration is that types are where a value's sensitivity gets declared or lost — stringifying structured data through map(string) loses the ability to mark one field sensitive, which matters in Secrets & Sensitive Data.

Blast radius

Small, with two exceptions. Converting a list to a set de-duplicates and discards order; changing a map key changes anything derived from it. Where a collection determines resource identity, both are replacement events, and the plan is the only place that shows it. Otherwise, type changes fail at plan time — the safest kind of failure, since nothing has happened yet.

Scale

Types cost nothing at runtime, but two patterns cost at plan time: very large collections built by expressions must be fully evaluated before planning begins, and deeply nested object types over hundreds of elements make errors hard to locate. The practical limit you'll meet first is human — an object type nested four levels deep is unmaintainable regardless of whether Terraform minds.

Team workflow

The convention worth agreeing and writing down: explicit types on every input, object for structured data, map(string) reserved for genuinely arbitrary key–value data, and any requiring a comment justifying it. It's enforceable in review and not by tooling, which is exactly the kind of rule that needs writing down. Adding the language server to the recommended editor setup does more for consistency than any amount of documentation.

Reliability

The type system's contribution is that a large class of mistake fails at plan time rather than at apply time — before anything exists. What it cannot do is check values against provider constraints, so the reliability gap is precisely "correctly typed but unacceptable", which is a linting and testing problem rather than a language one. Worth knowing where the boundary is, because it's easy to assume validate passing means more than it does.


Interview Questions

Conceptual

What is HCL, and why not just use JSON or YAML?

HashiCorp Configuration Language — a declarative configuration language with blocks, arguments and expressions, plus a type system. The case against JSON and YAML is that neither can compute: no expressions, no references between values, and in JSON's case no comments. Infrastructure inevitably needs "this value derives from that one", so a format without expressions grows a text-templating layer on top — CloudFormation's intrinsic functions, Helm's Go templates — and templating a structured document is fragile because the templating layer doesn't understand the structure it's producing.

HCL puts expressions inside the language where they can be type-checked. A JSON equivalent exists (.tf.json) for machine generation, and it has to embed expressions in strings, which shows the point.

What's the difference between a block and an argument?

A block declares something: a block type, zero or more labels, and a body in braces — resource "aws_s3_bucket" "demo" { ... }. An argument sets a named value inside a block, with an =.

The confusing case is that an argument's value can be an object, so tags = { ... } and versioning_configuration { ... } look similar. The = is the tell. Which one a piece of provider schema is, is fixed by the provider, and it matters beyond syntax: an argument's value can be produced by any expression, whereas generating a variable number of nested blocks requires dynamic.

What does it mean that a `.tf` file is evaluated rather than executed?

Terraform reads the whole configuration, works out what depends on what, and evaluates in dependency order rather than top to bottom. Consequences: declaration order is irrelevant within and between files; nothing can be reassigned, because names are bound to expressions rather than holding mutable state; a local referring to a later local is fine; and a circular reference is a parse-time error rather than a loop.

This is also why the main.tf/variables.tf split is convention — file organisation has no semantics — and why there's no way to express "do this, then that" in HCL directly.

List the HCL types.

Primitives: string, number, bool. Collections, homogeneous with an element type: list(T), set(T), map(T). Structural, with per-position or per-attribute types: tuple([...]) and object({...}). Plus any, which is a placeholder resolved at the point of use, and null, which is a value meaning "not set" rather than a type.

The distinction worth drawing unprompted: collections say every element has the same type; structural types type each element or attribute individually. So object is for structured input and map for arbitrary key–value data.

Why does `null` matter, and how is it different from `""`?

null means the argument was not set, so the provider applies its own default. "" is a value you chose — the empty string — which many arguments reject or interpret differently. The practical use is conditionally setting an argument: var.enabled ? true : null leaves it unset when disabled, which produces no plan entry at all, whereas false or "" would explicitly set something.

One subtlety: null as an element inside a collection is a real element whose value is null, which is a different thing and a common source of confusion when building lists conditionally.

Technical depth

What's the difference between a `list` and a `set`, and why does it matter?

A list is ordered, indexable and permits duplicates. A set is unordered, de-duplicated and cannot be indexed at all — toset(["a"])[0] is an error, and you must tolist() first, choosing an arbitrary order to do so. Sets have no literal syntax, so you write a list and convert, which is why the difference looks academic on the page.

It matters because sets de-duplicate silently — three elements in, two out, no warning — and because Terraform renders sets sorted for display, which misleads people into believing they're ordered. It matters most where a collection determines resource addresses: a set's elements can serve as identities, positions in a list cannot safely, and that's the substance of Meta-Arguments.

What is `["a", "b"]` — a list or a tuple?

A tuple — specifically tuple([string, string]). Terraform converts it to list(string) when it's assigned somewhere expecting one. terraform console shows this directly: type(["a","b"]) reports a tuple.

It matters when there's nothing to convert against. Assigning a mixed literal like ["a", 1] to a variable typed list(string) converts the number to a string; assigning it to list(number) fails. And with no declared type, it stays a tuple, so a downstream function expecting a list may complain about something you thought you'd already got right.

What automatic type conversions does Terraform perform?

Those with exactly one sensible answer: string to number and back where the string parses; string to bool for "true"/"false" and back; tuple to list when every element converts; list to set, losing order and duplicates; object to map when every attribute converts to the element type. Anything converts to any, which just defers.

Two consequences. 3 == "3" is true, because comparison converts first. And conversion happens at assignment and is not reversible — a variable typed set(string) given ["a","a"] holds one element permanently.

When would you use `object` rather than `map`?

object when the attributes are known and have different types — a bucket configuration with a bool for versioning and a number for retention. map when keys are arbitrary data and all values share one type, tags being the canonical case.

The practical argument for object: errors name the attribute that's wrong. Squeezing structured data into map(string) forces numbers and booleans to be stringified, loses type checking, and produces errors about map elements rather than about the field you got wrong. With optional() and defaults, object types are usable for real input without forcing every caller to supply everything.

Why is `any` discouraged?

Because it doesn't remove the type requirement, it postpones it. Terraform infers a concrete type where the value is used, so an error that could have been "variable buckets expects a bool for versioning_enabled" becomes a message about a nested element inside a resource argument, several steps from the mistake. It also removes the documentation value of the declaration — a reader can't tell what to pass — and it defeats editor completion.

The legitimate uses are narrow: genuinely heterogeneous pass-through data in a module wrapper. Those deserve a comment explaining why.

How does this differ across AWS, Azure and GCP?

The type system doesn't differ at all — it's Terraform's, and the same types and conversions apply everywhere. What differs is the value constraints providers place on identically-typed arguments, and metadata is the clearest case.

All three take a map(string). AWS and Azure call it tags and accept mixed-case keys and values. GCP calls it labels and requires lowercase keys and values with a restricted charset — so the exact same map that works on AWS fails on GCP, having passed terraform validate on both. The maintainable answer is one canonical map plus a for expression normalising it, not two hand-maintained maps.

Naming is the other case: Azure storage account names must be 3–24 lowercase alphanumeric characters, so a name assembled from a prefix and an environment contains invalid hyphens and needs string transformation rather than plain interpolation.

The general lesson is that "correctly typed" and "acceptable to the provider" are different properties, and only the first is checked before apply. Canonical table in Providers & the Registry.

Scenario and design

Design the input type for a module creating several buckets with per-bucket settings.

map(object({...})), keyed by logical name. The map key is the bucket's identity, so it should be something stable rather than data likely to be edited; the object carries the per-bucket settings with each attribute individually typed, and optional(type, default) for anything with a sensible default so callers supply only what differs.

What I'd avoid: any, which defers every error to the point of use; parallel lists of names and settings, which couples them by position and breaks the moment someone reorders one; and map(map(string)), which stringifies booleans and numbers and loses type checking entirely.

I'd also add a validation block asserting the keys match the character set that makes a valid resource address, since the keys become part of resource addresses — a constraint no provider will check.

You need one tagging convention across AWS, Azure and GCP. How do you implement it?

One canonical map as the source of truth — mixed case, human-readable — then a derived version for GCP produced by a for expression that lowercases keys and values and replaces hyphens with underscores. Both live in locals in one place, ideally a small shared module, so the convention has a single definition.

What not to do: maintain two maps by hand. They diverge within a month and nothing detects it, because both are valid map(string) and both pass validate.

Two things to watch. Some values genuinely can't be lowercased without losing meaning — a cost centre code with meaningful case — so those need normalising at the source rather than in the transformation. And cloud services add their own tags, which shows up as permanent drift unless ignore_changes concedes those specific keys.

A colleague reorders a list variable alphabetically "for tidiness". When is that dangerous?

Whenever anything derives identity from position. If the list feeds something that indexes into it — positionally-addressed resource instances, subnet CIDR allocation by index, priority-ordered rules — then reordering re-associates every element with a different thing, and for immutable attributes that means replacement. A one-line tidy-up can propose destroying most of a configuration.

It's safe if the list is only ever used for membership. The reliable way to find out is the plan, not reasoning: if it shows replacements, the order was load-bearing.

The design fix is to make it a set when order doesn't matter, so the question can't arise, or a map keyed by name when the elements are distinguishable things. Detail in Meta-Arguments.

How would you build an IAM policy document in Terraform, and why?

jsonencode over a native HCL object, or the provider's policy-document data source where one exists. Both give type checking, correct escaping of interpolated values, and output that cannot be malformed. The data source additionally validates structure against what the provider expects and merges statements cleanly.

What to avoid is a heredoc containing JSON with interpolations. It works until an interpolated value contains a quote or a newline, at which point you generate invalid JSON — and the failure mode for a policy document is worse than a syntax error, because a subtly wrong policy can be accepted and grant more than intended. This is the one place where "use the typed construct" is a security argument rather than a style preference.


Commands & Gotchas

terraform console                       # interactive evaluator — the type system's best tool
> type(expression)                      # what type is this, really
> tolist(x) / toset(x) / tomap(x)       # explicit conversion
> tostring(x) / tonumber(x) / tobool(x) # explicit primitive conversion
terraform fmt -recursive                # canonical formatting; changes no semantics
terraform validate                      # type-check against provider schemas, no credentials
terraform console -var-file=dev.tfvars  # evaluate with real inputs
Behaviour Why it matters
A .tf file is evaluated, not executed Declaration order is irrelevant; nothing can be reassigned
["a","b"] is a tuple until converted Matters when there's no declared type to convert against
set de-duplicates silently and has no order Three in, two out, no warning. Sets are rendered sorted, which misleads
A set cannot be indexed tolist() first, and accept an arbitrary order
3 == "3" is true Comparison converts first
Conversion happens at assignment and isn't reversible A set(string) given duplicates loses them permanently
null means "unset"; "" is a value condition ? value : null is the idiom for optional arguments
tags = {} is an argument; block {} is a block The = is the tell, and it decides whether dynamic is needed
validate checks types, not provider value rules Correctly typed and unacceptable is a real state. That's what tflint is for
any defers errors rather than removing them The error names a nested element instead of your mistake
optional(type, default) needs Terraform 1.3+ ⚠️ verify. It's what makes object types usable for input

← Back to The Language · Next: Providers & the Registry →


⚠️ Verification checklist (delete before publishing)

terraform console output — the central demonstration, and every line needs a real capture

  • type("hello"), type(3) — confirm exact output strings.
  • type(["a","b"])confirm it reports tuple([string, string]). The page makes this a headline claim in three places, including an interview answer.
  • type(tolist(["a","b"]))list(string).
  • type({env = "dev", size = 3}) — confirm the object({...}) rendering and its exact formatting.
  • toset(["b","a","b"]) — confirm the multi-line toset([...]) rendering shown.
  • toset(["a","b"])[0] — capture the real error text; mine is paraphrased.
  • 3 == "3"true. ⚠️ verify. If this is actually false, remove it from the page and the Commands table, and reconsider the "conversion before comparison" claim.
  • null == ""false.
  • tonumber("five") error text.
  • Confirm terraform console works with only a terraform {} block and no provider — the page tells the reader to start that way, so if init fails without a provider the whole opening demonstration needs restructuring.

Plan output

  • The tags plan showing Retention = "30"confirm the number is stringified as claimed. This is the point of the section.
  • The Incorrect attribute value type error for assigning an object into map(string), including whether it really names the map key.
  • The "Unsupported argument / Did you mean to define a block" error wording.
  • The Invalid value for input variable error wording.

Language claims

  • That a second locals block re-defining the same name is an error rather than an override.
  • That $${...} is the correct interpolation escape.
  • That <<-EOT strips leading indentation and <<EOT doesn't.
  • optional(type, default) minimum version — I wrote 1.3, flagged inline.
  • That identifiers may not begin with a digit, and that hyphens are permitted in resource names.
  • The full conversion table. Each row needs checking individually; object → map and list → set are the two I'm least sure of as automatic rather than explicit conversions.
  • Whether number is arbitrary-precision or float-backed. Deliberately not stated on the page — don't add a claim here without verifying, and if precision behaviour is surprising it may deserve a gotcha row.

Provider-specific claims in the tab set

  • GCP labels: lowercase keys and values, and the exact permitted charset. The page asserts an AWS map will be rejected rather than silently coerced — verify which.
  • AWS S3 bucket naming: 3–63 chars, lowercase, hyphens.
  • Azure storage account naming: 3–24 lowercase alphanumeric. Same item as three earlier pages — verify once.
  • That aws_s3_bucket accepts object_lock_enabled as used in the null example, and that passing null genuinely omits it.

Versions and syntax

  • ~> 5.0 aws constraint — now stale on seven pages. One sweep.
  • Terraform Language Server / editor extension names before recommending any specifically.

Structure

  • One <Tabs> block, for provider value constraints on identically-typed arguments. Confirm that's the right single use for a language page.
  • terraform destroy placement. The Getting Started section runs a console exploration that creates nothing, then a small apply that does, and ends with destroy. Confirm that reads correctly rather than looking like the destroy belongs to the console section.
  • Length: source is shorter than the Stage 2 pages. Check rendered length is comfortably inside target.
  • All relative links resolve; ./02-providers.md onwards don't exist yet.