Strings, Numbers, Booleans — and the Conversions Terraform Does For You
HCL values come in six types: string, number, bool, list (and set), map, and object — plus the special null, which means "unset" and lets you distinguish "the provider should pick a default" from "the empty value". The literals are terse:
locals {
name = "api-gateway" # string: double quotes only
replicas = 3 # number: integers and decimals, arbitrary precision
enabled = true # bool: true / false
nothing = null # null: intentionally unset
}
Two literal rules bite beginners immediately. First, strings are double-quoted only: writing 'prod' produces the error "Single quotes are not valid. Use double quotes (") to enclose strings." — a deliberate choice that keeps every string one convention. Inside double quotes, backslash escapes work (\n, \t, \", \\), and the sequences $${ and %%{ produce literal ${ and %{ without starting an interpolation — the escape hatch you will need by the end of String Interpolation and Templates. Second, numbers are real numbers, not floats: HCL carries arbitrary-precision decimals, so 0.1 + 0.2 == 0.3 evaluates to true in terraform console — unlike JavaScript or binary-floating-point languages engineers habitually compare it to. Leading zeros, hex, and octal notations are not number literals; 0x1F is a parse error.
null deserves its own paragraph because it appears in almost every diff you will ever read. Setting an argument to null is not the same as deleting it or using an empty string: null tells Terraform "no value specified", so the provider applies its own default — for many resource arguments that means the attribute is simply omitted from the API call. You will see -> null constantly in plan diffs when attributes are removed from configuration, and understanding that null flows into provider logic (where defaults live) prevents the "why did it reset to the provider's default?" surprise.
The conversion rules are where HCL quietly helps — or quietly surprises. Terraform converts types automatically whenever the intent is unambiguous: a number assigned to a string argument becomes its decimal representation (instance_type = var.count style values stringify without ceremony), a tuple passed where a list is expected converts element-by-element, an object converts to a map when the keys line up. What it will not do is guess at strings that look like numbers: "42" from a variable stays the string "42" until you call tonumber(), and arithmetic on it fails with "Invalid argument: argument must be a number". The console is the fastest place to internalize all of this — terraform console, then try type("3"), "3" + 4 (error), tonumber("3") + 4 (7), tolist(["a", 1]) (error — list elements must share a type). Five minutes of that beats an hour of reading, and the next two chapters build the collection types on top of these primitives.
There is one more primitive-adjacent concept to plant now, because plan output leans on it constantly: Terraform distinguishes a known value from a value it cannot know yet at plan time — the unknown, printed as (known after apply). Unknowns have full types but no value, and they are how a plan can reference an object that does not exist yet without failing. You will see them on every generated ID, and the full mechanics arrive in Attributes, References, and Meta-arguments.
Tuples Versus Lists, Objects Versus Maps: Why Literals Have Names You Do Not Expect
HCL has four collection types, and its literals produce the two the language calls unified types: square brackets make a tuple, curly braces make an object:
locals {
ports = [80, 443, 8080] # tuple(number, number, number)
mixed = ["eu-west-1a", 2, true] # tuple(string, number, bool) — allowed!
sizes = { small = "t3.micro", big = "m5.large" } # object with attributes small, big
counts = { dev = 1, prod = 5 } # object with number attributes
}
A tuple records element positions: its type is the sequence of its elements' types, and its length is part of its identity. That is why a heterogeneous tuple like ["eu-west-1a", 2, true] is perfectly legal — nothing requires elements to match. A list, by contrast, is homogeneous: one element type, any length. A tuple converts to a list only when every element converts to a common type, which is why tolist(["a", 1]) errors: there is no type both "a" and 1 can honestly become.
An object records attribute names and their individual types — [string, number]-style precision, but for named fields. A map is homogeneous: every value one type, any keys. Indexing differs accordingly: tuples and lists by numeric position, objects and maps by string key.
local.ports[0] # 80 — positions are zero-based
local.sizes["small"] # "t3.micro"
length(local.ports) # 3, works on any collection
Why should you care about the distinction when both are so similar in practice? Because Terraform uses literal types in exactly the places that demand precision. A tuple type like tuple([string, number]) can pin the shape of a two-element configuration value — first a name, then a port — while a list(string) constraint accepts any number of names. Provider arguments often tolerate the tuple you passed by converting it at the boundary, but when you see plan output announce (known after apply) alongside a tuple type, or an error like "Invalid value for 'list' parameter", you are looking at this exact machinery.
Sets deserve one note of their own before the workhorse pattern: a set(string) is unordered and deduplicates, which makes it the right type for "a collection of unique things" and the wrong type for anything positional — you cannot index a set, and its iteration order is not a contract. Terraform creates sets implicitly whenever you write toset([...]), and for_each over a set is the idiomatic way to make one resource instance per unique value. And when a collection's elements are themselves objects — say, a list of ingress-rule objects — you get the workhorse pattern of real configurations:
ingress_rules = [
{ port = 80, cidr = "10.0.0.0/8" },
{ port = 443, cidr = "0.0.0.0/0" },
]
which for expressions and dynamic blocks consume in topics ahead. One last literal syntax detail, because it appears in nearly every module: inside object literals the key may also be written with a colon ({ a : 1 }), and when the key itself must be an expression you wrap it in parentheses — { (var.env) = 1 } — otherwise bare identifiers are taken as literal attribute names, not references.
Constraining Inputs: object({....}), optional(), and Validation Blocks
A module's interface is its variable blocks, and the type argument is what turns "please pass a region" into a contract the compiler enforces before any API call. Left unset, a variable accepts anything (any), which pushes errors downstream to the provider call where they are expensive and confusing. Set properly, mistakes surface at plan time as "Invalid value for variable" with your own message attached.
The full ladder of constraints:
variable "instance_type" {
type = string
default = "t3.micro"
}
variable "subnet_ids" {
type = list(string) # homogeneous list of strings
}
variable "tags" {
type = map(string)
default = {}
}
variable "listener" {
type = object({
port = number
protocol = string
certificate_arns = optional(list(string), [])
})
nullable = false
}
The object({...}) block is the precision instrument. Each attribute gets its own type; required attributes simply lack the modifier. Since Terraform 1.3, optional(type, default) marks an attribute as omittable — with a default if you supply one — so callers may write { port = 443, protocol = "HTTPS" } and certificate_arns fills in as []. Before optional() existed, modules had to declare nullable variants of every attribute and normalize with coalesce() in ten places; if you maintain older modules, that is the reason for the archaeology. Two sharp edges: an omitted optional() attribute without a default becomes null, and defaults are evaluated once against other values in the same type expression, so optional(string, var.fallback) style cross-references are not allowed — defaults must be literals.
Conversion still applies at the boundary: pass the tuple ["subnet-1", "subnet-2"] where list(string) is declared and it converts silently; pass [{ id = "x" }] and it fails, because a tuple of objects does not convert to a list of strings. Then tighten further with a validation block — the assertions that make a module self-defending:
variable "environment" {
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be one of: dev, staging, prod."
}
}
Validation conditions are evaluated at plan time (after any type conversion), must reference the variable through var.<name>, and may appear more than once for independent rules. When one fails, the output names the variable and prints your message under "Invalid value for variable", which is exactly the feedback loop you want instead of a provider stack trace three applies later.
Two supporting players round out the type system. any — the bare type = any — accepts a value of any type without conversion, which is correct for variables that genuinely pass through to another consumer (a list of CIDRs that a module forwards untouched) and wrong for anything your own expressions process, since it moves errors downstream. And nullable (default true) controls whether null may be passed at all: with nullable = false, an explicit null is rejected at plan time rather than flowing into provider defaults, which is how you express "this value must exist". Locals need no type declarations — their types are inferred from their defining expressions — but the same conversion machinery applies when a local is used where a different shape is required.
For cross-variable checks — "the replica count may exceed 1 only in prod" — the same mechanism lives on outputs and resources as preconditions and postconditions, which you will meet when you study resource lifecycle. The habit worth forming now: every variable in a shared module gets a type and at least the validation that keeps it out of the provider's error messages. Where those variables get consumed — through references, assignments, and meta-arguments — is the next topic: Attributes, References, and Meta-arguments.
Quiz
1. In terraform console, what is the result of `0.1 + 0.2 == 0.3`?
2. A variable declared as `type = list(string)` receives the value `["a", 1, true]`. What happens?
3. Inside `optional(list(string), [])`, what do the two arguments do?
4. What does setting an argument to null mean compared to deleting it or using ""?
Flashcards
Question
What are HCL's six value types, and what does null mean?
Click to reveal answer
Answer
string, number, bool, list/set, map, object — plus null as the "unset" value. Null means the argument was intentionally not specified: the provider applies its default or omits it from the API call. Strings are double-quoted only; numbers are arbitrary-precision decimals.
Question
Tuple vs list, object vs map — what does each literal produce and record?
Click to reveal answer
Answer
`[a, b]` creates a tuple recording per-position types (heterogeneous, fixed length); `list(T)` is homogeneous with any length. `{k = v}` creates an object recording per-attribute types and names; `map(T)` is homogeneous values under string keys. Tuples/objects convert to list/map only when a common type exists.
Question
Why does `"42" + 1` fail while `tonumber("42") + 1` works?
Click to reveal answer
Answer
HCL does not parse strings that look like numbers; a string stays a string until you convert explicitly with tonumber()/tostring(). Automatic conversion happens only at unambiguous type boundaries (number → string, tuple → list), never by guessing intent in arithmetic.
Question
What does optional(type, default) do inside an object type constraint, and what happens without a default?
Click to reveal answer
Answer
It marks the attribute omittable, filling the given default when the caller omits it (Terraform 1.3+). Without a default the omitted attribute becomes null, leaving the provider to apply its own default. Defaults must be literal values within the type expression.
Question
What is a variable validation block, when does it run, and what must it reference?
Click to reveal answer
Answer
An assertion (`condition` + `error_message`) evaluated at plan time after type conversion, failing the run with "Invalid value for variable" and your message. The condition must reference the variable as var.<name>; multiple validation blocks express independent rules, and the same mechanism appears as preconditions/postconditions on outputs and resources.
Revision Notes
Key Takeaways
- 1. Strings are double-quoted only, numbers are arbitrary-precision decimals (no float artifacts), and null is a real value meaning "unset — provider's default applies"
- 2. Automatic conversion is deliberate and narrow: number→string, tuple→list, object→map when types unify; string→number always requires `tonumber()`
- 3. Literals produce tuples and objects (precise, positional/attributed); constraints declare lists and maps (homogeneous); conversion at the boundary is where most "Invalid value for variable" errors are born
- 4. `object({...})` with `optional(type, default)` (Terraform 1.3+) is the modern module interface: per-attribute types with omittable fields instead of nullable-everything plus coalesce()
- 5. `validation` blocks turn your module's rules into plan-time failures with your message — write them for every variable whose bad value would otherwise die inside a provider API call
Interview Tips
- • "What types does HCL have?" — string, number, bool, list/set, map, object, plus null; add the nuance that literals yield tuples and objects, which convert to list/map only when element types unify
- • "Why is my arithmetic failing on a variable?" — the variable is a string; HCL never guesses string→number, so `tonumber()`/`tostring()` is the explicit conversion; show the same for `can(var.x)` guards on optional values
- • "How do you make a robust module interface?" — declare `type` for every variable, use `object()` with `optional()` defaults, `nullable = false` where unset should be impossible, and `validation` blocks with messages that name the accepted values
- • "Explain null in a diff." — `-> null` means the argument was removed from configuration and the provider's default (or omission from the API call) now applies; distinguish it from setting the empty value, which is an explicit choice the provider must honor
Cheat Sheet
HCL Types
Literals: "text" string (double quotes only — single quotes error) · 3, 2.5 number (arbitrary precision; 0.1 + 0.2 == 0.3) · true/false bool · null unset · [...] tuple (per-position types) · {...} object (per-attribute types).
Key conversions:
| From → To | Automatic? |
|---|---|
| number → string | yes (string args stringify) |
| tuple → list | only if elements share a convertible type |
| object → map | only if attribute types unify |
| string → number | no — use tonumber() |
| bool → string | no — use tostring() |
Variable type ladder:
variable "listener" {
type = object({
port = number
protocol = string
certificate_arns = optional(list(string), [])
})
nullable = false
validation {
condition = contains(["HTTP", "HTTPS"], var.listener.protocol)
error_message = "protocol must be HTTP or HTTPS."
}
}
Indexing: tuple/list by position (ports[0]), object/map by key (sizes["small"]); length() works on all collections. Object-literal keys may be expressions when parenthesized: { (var.env) = 1 }.