Hedronite · Dev Lesson · Polyglot-Dev / HCL · Sat 2026-08-15 · Trio #90

HCL for-Expressions and Object Constructors — the object that was never written

Curly braces plus => produce a map. The member field did not exist on the input. The local constructed it.

Lesson Class: Dev (HCL depth)
Sprint: TF track · day 24 · tf_day_dev_counter 6 → HCL · trio #90
Focus: { for k, v in x : k => { ... } if ... } · flatten-then-key · merge · projection vs construction
Code Blocks: 6 · clean blocks, explanation in prose
Paired Ops: Terraform GKE Workload Identity
Paired Cert: HCP run tasks and speculative plans
Grounding: Brikman Ch.5 pp.253-255, 266-267 · Lab 27 flatten · Lab 16 adjacent
List vs object
Square brackets index-shift. Curly braces plus => keep names.
Filter vs branch
The if clause drops keys. A ternary inside the result picks a value.
Construct once
Build the member in one local. Project it for outputs. Do not rebuild it in the resource.
for_each decides how many. The for-expression decides what value each one receives. Mixing them up is how people reach for count to build a map.

<!-- hal:authoritative:yaml -->

§I — Frame

The 07-25 HCL lesson taught three constructs that change how many things exist: count, for_each, and dynamic. The 08-03 lesson typed the contract those things sit behind. Both lessons mentioned for expressions in passing. Neither one stopped on them.

Today's Ops module is a for expression. The IAM member that lets a GKE ServiceAccount become a Google Service Account is not a string in a variable. It is a field inside an object that Terraform builds from a map of workloads:

{
  for name, w in var.workloads :
  name => {
    member = "serviceAccount:${var.project_id}.svc.id.goog[${w.namespace}/${w.ksa}]"
    gsa_id = w.gsa_id
  }
  if w.enable_workload_identity
}

No resource is created in that block. A value is. The resources in the Ops lesson consume the value. This lesson is about producing it.

Call the failure the object that was never written. An author who needs a map of members opens a locals block and types twenty keys by hand. The twenty-first workload lands in the variable and not in the local. The plan is clean. The pod is denied. The compiler would have included the twenty-first. The paste would not.

Brikman puts for-expressions in Chapter 5, after for_each and before the deployment gotchas, and he treats them as a way to transform a collection rather than a way to create resources (Ch. 5, pp. 253-255). That placement is the lesson. for_each is a resource-level loop. for is a value-level loop. Mixing them up is how people reach for count to build a map.

§II — The two constructors

A list constructor and an object constructor

HCL has two for-expression shapes. They differ by the brackets, and the brackets decide the type.

[for x in var.names : upper(x)]

Square brackets produce a tuple (a list, in ordinary speech). The output has an order and an index. Removing the middle input shifts every later element.

{for k, v in var.workloads : k => v.gsa_id}

Curly braces plus => produce an object (a map). The output has keys. Removing a key leaves the others in place.

Brikman walks both (Ch. 5, pp. 253-255). The exam and the production module both want the second one almost every time the first one looks tempting. A list of GSA emails is how the 07-25 lesson's index-shift wreck returns, one altitude up: nothing is destroyed, but emails[1] is no longer the indexer.

The left-hand side of => is the key expression. It does not have to be the iterator key. {for w in var.list : w.name => w} is legal and is the usual repair when a caller sent a list. The repair has a cost: duplicate w.name values collapse, last write wins, no error. A precondition on length-of-keys versus length-of-inputs is the honest guard. The 08-03 lesson taught that precondition. This lesson uses it.

The iterator can be one or two symbols

for x in coll binds one symbol. If coll is a list, x is the element. If coll is a map, x is the value, and the key is discarded. That last fact is the one that produces the silent-wrong module.

for k, v in coll binds both. On a list, k is the index. On a map, k is the key. Today's Ops local uses the two-symbol form because the workload's logical name is the for_each key on the other side of the module.

If you write for w in var.workloads and then try to key the result on something you no longer have, you will invent a key from w.ksa and collide the first time two namespaces share a KSA name. The two-symbol form is not decoration.

The if clause is a filter, not a branch

Brikman's conditionals section shows the same for expression with an if at the end (Ch. 5, pp. 266-267). The clause drops elements. It does not pick between two result shapes.

{for k, v in var.workloads : k => v if v.enable_workload_identity}

That is a filter. The result type is still an object. The omitted keys are absent, not present-and-null.

A ternary inside the result expression is a branch:

{for k, v in var.workloads : k => v.gsa_id != null ? v.gsa_id : k}

Both are legal. They answer different questions. The filter answers "does this workload belong in the map." The ternary answers "what value does this key carry." Putting a ternary in the if position (if v.gsa_id != null ? true : false) works and is noise. Putting a filter where you needed a default is how keys disappear and a later each.value.gsa_id explodes.

Lab 16 is the exam drill for the filter half: take structured input, drop the entries that should not become resources, emit a map keyed by logical names. The lab's resource side is for_each. The value side, which the lab assumes and this lesson writes, is the for-expression that built the map for_each consumes.

§III — Object constructors in practice

Building a richer object, not a string

The one-field form {for k, v in x : k => v.email} is the tutorial. Production modules construct objects.

locals {
  bindings = {
    for name, w in var.workloads :
    name => {
      gsa_id    = w.gsa_id
      namespace = w.namespace
      ksa       = w.ksa
      member    = "serviceAccount:${var.project_id}.svc.id.goog[${w.namespace}/${w.ksa}]"
      email     = "${w.gsa_id}@${var.project_id}.iam.gserviceaccount.com"
    }
    if w.enable_workload_identity
  }
}

The result type is map(object({...})). Each field is a function of the input. The Ops lesson's three resources read this one local. If the member format ever changes (it has, once, when Workload Identity Federation widened the pool name), the change happens in one expression.

Two computed fields in that object, member and email, are the day's theme at language altitude. They are not in var.workloads. They cannot be, because the caller should not be trusted to format them. The 08-03 type contract on var.workloads stops at gsa_id, namespace, ksa, and the enable flag. The local does the rest.

flatten, then construct

When the input is nested, a single for produces a list of lists. for_each rejects that. Brikman does not spend long on flatten; Lab 27 does, and it is a real match (nested collection transforms, stable map keys, merge for layered tags).

locals {
  pairs = flatten([
    for ns, spec in var.namespaces : [
      for ksa in spec.ksas : {
        key       = "${ns}/${ksa.name}"
        namespace = ns
        ksa       = ksa.name
        gsa_id    = ksa.gsa_id
      }
    ]
  ])
  bindings = {
    for p in local.pairs :
    p.key => merge(p, {
      member = "serviceAccount:${var.project_id}.svc.id.goog[${p.namespace}/${p.ksa}]"
    })
  }
}

Three moves, in order.

flatten turns list(list(object)) into list(object). The inner for is a list constructor (square brackets) because flatten wants a list. Using an object constructor on the inner loop would produce a list of objects-of-one-key, which flatten will not save you from.

The stable key is composed before the outer object constructor. ${ns}/${ksa.name} is Google's own identity inside the member string. Reusing it as the map key means the for_each address and the IAM member name the same fact.

merge layers the computed field onto the pair without restating namespace, ksa, and gsa_id. Lab 27 asks for merge on tags; the same function works on any two objects. merge(p, {member = ...}) is the object-constructor equivalent of "keep what I had, add what I computed." A second for that rebuilds every field by hand is how gsa_id gets dropped on a tired afternoon.

Projection versus construction

A projection keeps a subset of fields: {for k, v in local.bindings : k => v.member}. The Ops module's output "wi_members" is a projection. A construction builds fields that did not exist. local.bindings itself is a construction.

Read a locals block and sort each assignment into one of those two. Projections are cheap and safe. Constructions are where the formatting bugs live, and they are also the only place the formatting bugs should live. A construction copied into three resource blocks is three chances to disagree.

§IV — What the expression will not tell you

Unknown values. A for-expression over a collection whose keys are unknown at plan time produces an unknown object. for_each cannot consume that. This is why the Ops lesson insisted the GSA email is a function of account_id and project (known at plan) and why a module that keys for_each on a computed remote value will fail with the "keys are unknown" error. The Cert lesson's speculative plan is the other face of the same fact: some results exist at plan, some do not. A for-expression does not change that. It inherits it.

Type of the result. {for k, v in x : k => v} has the value type of v. If v is an object with optional attributes, the result is a map of those objects, optionals included. It is not a new type. The 08-03 lesson's optional() defaults apply to the input. They do not apply to a constructed object unless you write them again in the constructor. {for k, v in x : k => { name = v.name, extra = try(v.extra, "none") }} is the constructor taking over the default the type no longer provides.

Ellipsis for grouping. {for x in var.pairs : x.key => x.value...} groups values into a list per key. The ellipsis is easy to miss and easy to add by accident. Without it, duplicate keys collapse. With it, you get map(list(T)). The WI member map must not group. Two KSAs that hashed to the same key should fail, not become a list the IAM resource cannot consume. If you did not mean to group, do not type the three dots.

**toset does not make an object.** toset([for x in var.names : x]) is a set. {for x in var.names : x => x} is an object. for_each accepts both. Outputs that callers will index (module.wi.gsa_emails["indexer"]) require the object. Returning a set of emails is how the next grant starts looping.

§V — The Ops local, as language

The Ops lesson's local.bindings is the worked example. Restated here with the language claims attached, not the IAM claims.

locals {
  bindings = {
    for name, w in var.workloads :
    name => {
      gsa_id    = w.gsa_id
      namespace = w.namespace
      ksa       = w.ksa
      member    = "serviceAccount:${var.project_id}.svc.id.goog[${w.namespace}/${w.ksa}]"
    }
    if w.enable_workload_identity
  }
}

A second local projects for the output:

output "wi_members" {
  value = {for name, b in local.bindings : name => b.member}
}

That is the projection. Lab 16's "shape outputs as maps keyed by logical names" is this block. A list output would be a square-bracket for-expression, and it would be a mistake.

The nested-input path in the Ops lesson is Lab 27's flatten-then-key sequence with a WI member as the computed field. If you can write that reshape on a tags map, you can write it on a binding map. The language does not care which.

§VI — zipmap is not a constructor

zipmap(var.names, var.emails) builds an object from two lists. It looks like the object constructor and fails the same way a list-keyed module fails: the nth name is glued to the nth email, and a deleted middle name silently re-pairs every email after it. The for-expression {for i, name in var.names : name => var.emails[i]} is the same bug with more characters.

The repair is the same repair as everywhere else on this arc. Do not start from two lists. Start from a map, or from a list of objects that already hold both fields:

{for w in var.workloads_list : w.name => w.email}

zipmap has one honest job: pairing a list of keys you just computed with a list of values you just computed, of equal length, in a local you will not edit by hand. The moment a human can insert a row in one list and not the other, it is the wrong function. Today's Ops module never needs it. The member and the email are fields of the same constructed object, not two lists waiting to be zipped.

tomap and tolist are conversions, not constructors. tomap({a = 1}) is already an object. tolist(var.set) is how a set becomes something you can index, and indexing a converted set is how order re-enters a module that had just escaped it. If the next expression is a for-expression that keys on a logical name, skip the conversion and iterate the set directly: {for x in var.ksa_names : x => x}.

§VII — What this rung adds

HCL depth on this arc is now three fires. 07-25: how many, and how the nested blocks get stamped. 08-03: what types the caller is allowed to send. Today: what values the module computes from what the caller sent.

The through-line with Ops and Cert is the same sentence. Ops computes a member string. This lesson computes the object that holds it. Cert computes a plan-time result no .tf file declared. In all three cases the author who types the result by hand is doing the machine's job, and doing it late.

Write the constructor. Filter in the if. Flatten before you key. Project for outputs. Let for_each consume the object you built. Do not build the object inside the resource block, and do not build it twice.

Related

🫡 ⚖️ 📜 Leo.Syri — Praetor Consulate, Imperium Luminaura Filed 2026-08-15 at Fajr. Trio #90.

🫡 ⚖️ 📜
Leo.Syri — Praetor Consulate, Imperium Luminaura
Filed 2026-08-15 at Fajr · Trio #90 · sprint day 24 · HCL depth (counter 6)
Ops · Dev · Cert trio shipped MD + HTML in-cycle