Chapter 4: Variables, Outputs, and Expressions
Topic 26

Expressions and Operators

Expressions

HCL expressions are how you compute values — arithmetic, comparison, logical operators, string templates, and references. They are the connective tissue between resources: an expression reads one resource's attribute, transforms it, and feeds it to another. Knowing what's available — and what isn't, because HCL is expression-based rather than a full language — keeps configs clean.

The mistake worth heading off early is building structured text by hand. The temptation to construct an IAM policy or a JSON document with string concatenation ends in invalid output the first time a value contains a quote or a special character. Expressions are for computing values; structured documents belong to the encoding functions covered next.

Operators and Precedence

HCL has arithmetic operators (+, -, *, /, %), comparison operators (==, !=, <, >), and logical operators (&&, ||, !). They follow conventional precedence — multiplication before addition, comparison before logical-and — but a compound boolean reads more clearly when you parenthesize intent rather than trusting a reader to recall the table.

locals.tf — operators in a condition
locals {
  # parenthesize compound booleans for explicit intent
  is_prod_ha  = (var.environment == "prod") && (var.replica_count > 1)
  subnet_bits = var.large_network ? 8 : 4
}

String Templates and Directives

Interpolation with ${...} embeds a value inside a string, and template directives %{if} and %{for} add inline conditionals and loops within a string. Directives are handy for a one-line user-data tweak, but building a whole collection inside a string template is harder to read than a for expression that produces the structure directly.

interpolation and a template directive
locals {
  bucket_name = "${var.project}-logs-${var.environment}"

  greeting = "Hosts: %{ for h in var.hosts }${h} %{ endfor }"
}

References and Traversal

A reference walks into a value: aws_instance.web.private_ip reads an attribute, var.subnets[0] indexes a list, and var.config["region"] indexes a map by key. List indexing is positional and errors on an out-of-range index; map indexing is by key and errors on a missing key unless you use lookup with a default. The distinction matters the moment a collection might not contain what you assume.

Type Behavior and null

Operators coerce compatible types — a number used in a string context becomes its string form — and null threads through expressions in ways that surprise people. A null argument means "use the provider default" rather than "set to empty", and a null flowing into an operator often produces a confusing error rather than a sensible fallback. When a value can be null, handle it explicitly with coalesce or a conditional rather than letting it propagate.

Heredocs for Multi-line Strings

A heredoc with <<-EOT holds a multi-line string — a user-data script, a templated policy document — without escaping every newline. The dash form strips leading indentation so the block can sit indented in the config and still produce a clean string. Heredocs are for literal multi-line text; for structured JSON, reach for jsonencode instead of writing the braces by hand.

main.tf — a heredoc user-data script
resource "aws_instance" "web" {
  ami           = "ami-0abc123"
  instance_type = "t3.micro"
  user_data     = <<-EOT
    #!/bin/bash
    echo "env=${var.environment}" > /etc/app.env
    systemctl restart app
  EOT
}
Common Mistakes
  • Building an IAM policy by hand with string templates instead of jsonencode, producing invalid JSON the first time a value contains a quote.
  • Misjudging operator precedence in a compound boolean and getting the wrong result for want of parentheses to pin the intent.
  • Overusing %{for} template directives to assemble a collection a for expression would build more clearly.
  • Letting null propagate through an expression and hitting a confusing "null value" error instead of handling it with coalesce.
  • Indexing a list with [0] when it might be empty, or a map with a key that might be absent, and erroring instead of falling back.
Best Practices
  • Build JSON with jsonencode and YAML with yamlencode rather than concatenating strings.
  • Parenthesize compound boolean and arithmetic expressions so precedence is explicit to the next reader.
  • Use heredocs (<<-EOT) for multi-line user-data and templated documents instead of escaped one-liners.
  • Prefer a for expression over a %{for} template directive when the output is a collection rather than a string.
  • Handle a possibly-null value explicitly with coalesce or a conditional rather than letting it thread through an operator.
Comparable tools CloudFormation intrinsic functions like Fn::Sub and Fn::Join are the awkward analog Pulumi uses the host language's own expressions directly Ansible uses Jinja2 templating for expressions

Knowledge Check

Why build an IAM policy document with jsonencode instead of a hand-written string template?

  • jsonencode escapes special characters, so a quote or backslash in a value can't produce invalid JSON
  • Hand-written string templates aren't permitted at all inside a resource argument that expects a JSON document
  • jsonencode encrypts the rendered policy document so it never appears in the state file
  • String templates can only embed literals and cannot reference variables at all

How does indexing a list differ from indexing a map when the element might be missing?

  • A list index is positional and an out-of-range index errors; a map is keyed and a missing key errors unless you lookup with a default
  • Both a list and a map silently return an empty string when the requested element is absent at plan time, rather than ever raising an error
  • A list returns null for any out-of-range index, while a map raises an error for any key you look up
  • Maps can only be indexed from inside a for expression and never directly with bracket syntax

When is a heredoc (<<-EOT) the right tool?

  • For literal multi-line text like a user-data script, where escaping every newline would be noise
  • For building a structured JSON policy document by hand, used in place of calling jsonencode on a map
  • For any string that needs to reference a variable through interpolation
  • For encrypting a multi-line value before it is written into the state file

A null value flows into a comparison and the plan errors. What is the cleaner approach?

  • Handle the null explicitly with coalesce or a conditional before it reaches the operator
  • Wrap the whole comparison expression in a heredoc string literal to suppress the plan error
  • Cast the null to an empty string by passing it through jsonencode first
  • Nulls are always coerced to false in a comparison, so the error is a Terraform bug

You got correct