Chapter 5: The Language — Variables, Outputs, Expressions
Topic 32

Expressions and Operators

Language

Everything to the right of an = in HCL is an expression — a literal, a reference, an operator chain, a function call, or a template that evaluates to a value. This topic is the grammar that the rest of the language is written in: arithmetic and comparison operators, string templates and heredocs for multi-line content, references across resources, and a first look at the conditional ? : the next chapters lean on.

None of it is exotic. The operators behave the way they do in any language, and the references are how the Hatch pipeline's resources point at each other. What is worth getting precise is where HCL coerces types and where it does not, and why a Terraform conditional type-checks both branches even though only one runs.

Operators

HCL has the operators you expect: arithmetic (+ - * / %), comparison (== != < >), and logical (&& || !). They behave as in any language. The common use in a configuration is computing a derived number — an instance count from an environment flag — or a boolean gate that switches a resource on or off.

References

A reference reads another value: var.region, local.common_labels, google_storage_bucket.raw.name, module.ingest.topic_id, data.google_project.current.number. Beyond reading, a reference between two resources is how Terraform infers dependency order — when the topic references the bucket's name, Terraform knows to create the bucket first, with no explicit depends_on needed.

A reference implies dependency order
resource "google_pubsub_topic" "events" {
  name = "${local.name_prefix}-events"
  labels = {
    raw_bucket = google_storage_bucket.raw.name   # bucket built first, inferred
  }
}

Because the topic's labels reads google_storage_bucket.raw.name, Terraform orders the bucket ahead of the topic automatically. The reference is the dependency — declaring it twice with depends_on would be redundant.

String Templates

A string template builds a string from expressions. Interpolation embeds a value with "${var.environment}-events-raw"; directives embed control flow with %{ if } ... %{ endif }. When you are not actually building a string — when the whole value is a reference — prefer the bare expression bucket.name over the wrapped "${bucket.name}". The wrapping adds nothing and reads as noise.

Heredoc Strings

A heredoc holds multi-line content: a startup script, an inline policy document, a Cloud Run environment value. The indented form <<-EOT ... EOT strips the leading indentation off every line, so the block can be indented to align with the surrounding code without those spaces ending up in the value. The plain <<EOT form preserves every character literally, including the indentation.

An indented heredoc strips leading whitespace
locals {
  startup = <<-EOT
    #!/bin/bash
    echo "starting hatch processor"
  EOT   # <<- strips the indentation; plain << would keep it
}

For free-form text a heredoc is right. For structured data — JSON, a policy document — jsonencode beats a hand-built heredoc, because it escapes correctly and a heredoc does not.

The Conditional Preview

The conditional expression var.environment == "prod" ? 3 : 1 selects a value by condition: the first branch when the condition is true, the second when it is false. It is the workhorse for "one in dev, three in prod," and it feeds directly into the count and for_each meta-arguments the iteration chapter covers in full. Here it is enough to read it: condition, then the true value, then the false value.

Type Conversion in Expressions

HCL coerces compatible types inside an expression — a number drops into a string template as its text form — and errors on incompatible ones. Knowing where it coerces and where it refuses prevents surprised plans. One sharp edge: a Terraform conditional type-checks both branches, so a ? : whose false branch references something nonexistent still fails to type-check even when the condition would never take that branch. && and || do not short-circuit around a type error the way they short-circuit a value.

Bare Expression vs Interpolation

Bare expressionlocation = var.region, count = var.enabled ? 1 : 0. Use it whenever the value is a reference or computation and you are not assembling a string. It is the idiomatic form and what terraform fmt expects.

Interpolation"${var.region}-raw". Use it only when you are genuinely building a string from parts. Wrapping a whole value in "${...}" when no string-building happens is legacy noise that fmt will not strip for you.

Common Mistakes
  • Wrapping a whole value in "${...}" when no string-building is happening — count = "${var.enabled ? 1 : 0}" should be count = var.enabled ? 1 : 0; the wrapping is legacy noise terraform fmt will not remove.
  • Building a JSON or structured string with a heredoc and manual interpolation, then shipping malformed JSON the moment a value contains a quote — use jsonencode, which escapes correctly, instead of a hand-built heredoc.
  • Forgetting that a non-<<- heredoc preserves literal indentation, producing a startup script or policy with unwanted leading spaces that break the script.
  • Assuming && and || short-circuit around an error — both sides of a Terraform conditional are evaluated for type-checking, so a "guard" expression that references something nonexistent on the false branch still has to type-check.
  • Concatenating a number into a name with string addition instead of a template — HCL coerces inside "${...}" but + on a string and a number is a type error, not concatenation.
Best Practices
  • Prefer bare expressions over "${}" interpolation except where you are genuinely assembling a string from parts.
  • Use the indented heredoc <<-EOT for multi-line literals so the block aligns with surrounding code, and reserve heredocs for free-form text, not structured data.
  • Build any JSON or policy document with jsonencode rather than a hand-interpolated string, so quoting and escaping are correct.
  • Keep conditional expressions simple and readable; if a ? : grows nested, lift the branches into a local the resource can reference cleanly.
  • Let a reference between resources express the dependency instead of adding a redundant depends_on Terraform would have inferred anyway.
Comparable tools Pulumi expressions in the host language Jsonnet expressions and if/then/else Jinja · Go templates (Ansible, Helm) the string-templating analog

Knowledge Check

When should you use a bare expression instead of "${...}" interpolation?

  • Whenever the value is a reference or computation and you are not assembling a string from parts
  • Only inside a locals block; every resource and module argument still requires the interpolation wrapper
  • Never — every value must be wrapped in "${...}" before HCL will evaluate it
  • Only for numeric values; any string reference still needs interpolation to resolve

What does the <<- form of a heredoc do that a plain << does not?

  • It strips the leading indentation off each line, so the block can be indented with surrounding code without those spaces ending up in the value
  • It automatically encodes the rendered content as JSON before assigning it to the argument
  • It enables ${...} interpolation inside the body, which a plain << heredoc forbids
  • It trims the trailing newline from the end of the block so the resulting string value carries no final line break at all, unlike a plain << heredoc

Why must both branches of a condition ? a : b type-check, even though only one runs?

  • Terraform evaluates both sides for type-checking, so a false-branch reference to something nonexistent fails the plan even when the condition is true
  • Terraform actually executes both branches against the provider at apply and then discards the unused result, so each one has to succeed without error first
  • The conditional is internally rewritten into a for_each that iterates over both candidate values
  • Type-checking only the taken branch would measurably slow the plan phase down on large graphs

Why build a GCP policy document with jsonencode instead of a hand-interpolated heredoc?

  • jsonencode escapes correctly, so a value containing a quote produces valid JSON; a hand-built heredoc ships malformed JSON
  • A heredoc cannot span more than one line, so it cannot hold a multi-statement policy document
  • jsonencode is the only expression the policy argument will accept, so a heredoc is rejected outright
  • A heredoc is only resolved at apply time, while jsonencode already renders its finished JSON output earlier during the plan phase

You got correct