HCL and Types
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
joinand string concatenation, usejsonencodeover a real object — it can't produce malformed output, and it type-checks. - Don't use
anyto 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.jsonby 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
HCL — the configuration language. HashiCorp Configuration Language, version 2. Files use the
.tf extension. A JSON-equivalent syntax exists in .tf.json for machine generation.
Block — a 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.
Argument — a named value inside a block. name = expression. Note the =.
Attribute — a 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 block — a 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.
Expression — anything that produces a value. A literal, a reference, an operator application, a function call. Expressions & Functions.
Identifier — a 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 type — a single value. string, number, bool.
Collection type — many 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 type — many 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.
any — a 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.
null — absent. 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 conversion — automatic 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 constraint — the 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.
Evaluation — working 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.

# 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
localsblock definingprefixagain 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.tfsplit 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

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

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 withtolist()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 missingtype. 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 wantsobject.- A
listwhere the elements are named things. Should it be aset, or amapkeyed 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}"wherevar.xwould do, and heredocs used for single-line strings. Cosmetic, but cheap to fix and it signals whether the author knows the language.""used wherenullis meant. Empty string is a value;nullmeans 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 reportstuple([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 theobject({...})rendering and its exact formatting. -
toset(["b","a","b"])— confirm the multi-linetoset([...])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 consoleworks with only aterraform {}block and no provider — the page tells the reader to start that way, so ifinitfails 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 typeerror for assigning an object intomap(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 variableerror wording.
Language claims
- That a second
localsblock re-defining the same name is an error rather than an override. - That
$${...}is the correct interpolation escape. - That
<<-EOTstrips leading indentation and<<EOTdoesn'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
numberis 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_bucketacceptsobject_lock_enabledas used in thenullexample, and that passingnullgenuinely omits it.
Versions and syntax
-
~> 5.0aws 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 destroyplacement. The Getting Started section runs a console exploration that creates nothing, then a small apply that does, and ends withdestroy. 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.mdonwards don't exist yet.