HCL Syntax and Language Fundamentals
HCL Syntax and Language Fundamentals
HCL is a small language with a few traps that show up as a plan that cannot be computed. This chapter is the syntax you need to read anyone else’s module.
Learning goals
- Parse a block into type, labels, and arguments
- Choose a type instead of stringifying everything
- Write a
forexpression and adynamicblock when repetition is data - Predict what
nulland an unknown value will do tocount
Blocks, Arguments, Comments, and Basic Structure
A block has a type, zero or more labels, and a body.
resource "random_pet" "service" {
length = 2
separator = "-"
}
resource is the type. "random_pet" and "service" are labels. length and separator are arguments. Argument order does not matter. A missing required argument is an error at plan time. An unknown argument is also an error, which is how typos die early.
# and // comment to end of line. /* */ comments a range. Comments are not documentation the registry renders. Prefer a description on variables and outputs for anything a caller must see.
Identifiers are letters, digits, underscores, and hyphens in some positions. Resource local names should be stable. Renaming service to api is a new address, and state will want to destroy the old one.
Attributes and Types: Strings, Numbers, Booleans, Lists, Maps, Objects
| Type | Literal | Use |
|---|---|---|
| string | "api" |
Names, ids you were given |
| number | 2 |
Counts and sizes. Do not quote numbers you intend to do arithmetic on |
| bool | true |
Flags. The strings "true" and "false" are strings |
| list | ["a", "b"] |
Ordered, one element type in simple declarations |
| set | toset(["a", "a"]) |
Unordered, unique. A set of strings is the natural for_each |
| map | { env = "prod" } |
String keys, one value type |
| object | { name = "api", port = 443 } |
Named attributes with their own types |
| tuple | ["api", 443] |
Fixed positions, mixed types |
A list and a tuple both use []. The type constraint you declare is what makes them different. Chapter 5’s variable types are these same types with validation on top.
Heredocs (<<-EOT … EOT) are strings. The closing marker sits at the left margin unless you use the dashed form, which strips the indentation of the least-indented line.
Expressions: Interpolation, Conditionals, and Operators
Inside strings, ${...} interpolates. "pet-${random_pet.service.id}" is a string. Outside strings, you do not wrap references in quotes or in ${}. random_pet.service.id is the value. "random_pet.service.id" is the text of that address and a classic bug.
Conditionals are condition ? then_value : else_value. Both branches are typed together. A branch that returns a string and a branch that returns a number will fail or coerce in a way you will not like. Make them the same type.
&&, ||, and ! are boolean. +, -, *, / are numeric. == and != compare. Comparisons against null are how optional values show up. Operator precedence will surprise you less if you parenthesise the conditional.
A splat aws_instance.web[*].id turns a list of resources into a list of attributes. On a resource that uses for_each, the value is a map, and you want a for expression rather than a splat.
For Expressions: Transforming Collections Elegantly
A for expression builds a new collection. It does not loop at apply time. It is evaluated while the configuration is being built.
locals {
ports = [80, 443]
listeners = [
for port in local.ports : {
port = port
protocol = port == 443 ? "https" : "http"
}
]
by_protocol = {
for listener in local.listeners : listener.protocol => listener.port...
}
}
The trailing ... groups duplicates into a list. Without it, duplicate keys are an error. A filter clause keeps some elements: for port in local.ports : port if port > 100.
for_each on a resource wants a set of strings or a map. A list of objects is the wrong shape until you turn it into a map keyed by a stable name. Keying by list index means a deletion in the middle recreates every later instance. Key by a name the human owns.
Dynamic Blocks: Generating Repetitive Configuration Programmatically
Some resources take repeated nested blocks (ingress, statement). You cannot put for_each on a nested block. dynamic generates the blocks from a collection.
locals {
ingress_ports = [22, 443]
}
# Sketch. The security-group type is the provider's, not part of the pet lab.
# resource "aws_security_group" "edge" {
# dynamic "ingress" {
# for_each = toset(local.ingress_ports)
# content {
# from_port = ingress.value
# to_port = ingress.value
# protocol = "tcp"
# cidr_blocks = ["203.0.113.10/32"]
# }
# }
# }
Inside content, ingress.value is the element and ingress.key is the key. Name the dynamic block after the nested block type the provider expects, not after a name you wish the provider had used.
Dynamic blocks hide structure. Three nearly identical blocks written out are easier to review than a dynamic block over a clever local. Use dynamic when the collection is data that changes size. Write the blocks when there are two of them and a human should see both.
Null, Unknown, and Type Conversion: Gotchas Every Engineer Must Know
null means “omit this argument” for many optional resource arguments. It does not mean empty string, and it does not mean zero. A default of "" still sets the argument. A default of null leaves it unset so the provider can apply its own default. Those produce different plans.
An unknown value is a value that will exist only after apply: the id of a resource being created in this same plan. Unknown values flow through expressions. They poison anything that must be decided during the plan, especially count and for_each. You cannot yet know how many instances to plan if the count depends on an id that does not exist.
OpenTofu 1.13 adds two functions aimed at that wall, still settling toward a stable design in 1.14. convert asks the planner to treat an unknown as a given type. assumenotnull tells the planner a value will not be null. If apply later violates the hint, OpenTofu errors instead of silently doing something else. Use them at a module boundary when a caller passes a value the plan cannot see through. Do not use them to paper over a count that should have been a static map.
tostring, tonumber, tolist, and tomap coerce explicitly. Implicit coercion of a bool to a string is how a tag set becomes "true" in one environment and true in another. Coerce at the edge.
try(expr, fallback) catches errors in expressions. It also catches typos. Prefer a type constraint over try when the failure is a mistake you want to see.
Summary
- Blocks have a type, labels, and arguments. Names of files do not change that
- Unquoted references are values. Quoted references are strings
forbuilds collections.dynamicbuilds nested blocks. Stable keys keep state stablenullunsets an optional argument. Unknown values cannot sizecount- 1.13’s
convertandassumenotnullare hints to the planner, checked again at apply