Each.value.<key> and nested maps not returning attributes (0.12)

I’m pursuing the authoring of an r53_zones module, and I’m having issues with the way for_each seems to be addressing my nested map data structure I’m using as input.

For example, given the below input variable:

variable "zones" {
  type = map(
    object({})
  )
  default = {
    "test0" = {
      "vpc_ids" = ["vpc-obscura", "vpc-blerg"],
      "force_destroy" = true,
      "tags" = {"test" = "true"}
    },
    "test1" = {
      "vpc_ids" = ["vpc-blerg"],
      "force_destroy" = true,
      "tags" = {
        "test" = "true",
        "zone_name" = "test1"
      },
    }
  }
}

And the below resource block:

resource "aws_route53_zone" "this" {
  for_each = var.zones
# Some lines
  force_destroy = each.value.force_destroy
# More lines
}

I get the plan output of:

on main.tf line 20, in resource "aws_route53_zone" "this":
20:   force_destroy = each.value.force_destroy
|----------------
| each.value is object with no attributes
This object does not have an attribute named "force_destroy".

But the object in question is an object with force_destroy as one of its keys. So what’s up with that? Or more aptly, is this a valid way to grab a key that’s nested in the parent map’s value which is defined in for_each? I’ve tried various forms of lookup() with similar results.

Using each.value[“force_destroy”] does not appear to work either, throwing a similar error in reference to an invalid index rather than an invalid attribute.

I figured it out.

In my variable, I have a type constraint map(object({})). The reason I didn’t define a schema in my object is because I don’t want to require any one of these attributes as input. It should be enough to give a zone name with an empty array to establish a public zone, and I didn’t want to crowd .tfvars with a long list of attributes for every zone.

According to the input variable documentation, you can have additional objects above and beyond the schema defined, so I opted to define no schema. The catch is that any attributes which are not defined in the schema are discarded on type conversion. I didn’t think this was an issue, because I didn’t think that I was converting any types.

There appears to be a type conversion involved in for_each, or in the use of each.value that resulted in the attributes of my objects being silently discarded. Removing the type constraint altogether makes this input variable work as expected. I don’t see any documentation that details exactly what type conversions are happening in for_each or each.value, so if someone could provide that it would probably help someone explain this down the road.

Looks like the below snippet is incorrect.
type = map(
object({})
)

When I try with below code, I get nothing.

variable "zones" {
  type = map(
    object({})
  )
  default = {
    "test0" = {
      "vpc_ids" = ["vpc-obscura", "vpc-blerg"],
      "force_destroy" = true,
      "tags" = {"test" = "true"}
    },
    "test1" = {
      "vpc_ids" = ["vpc-blerg"],
      "force_destroy" = true,
      "tags" = {
        "test" = "true",
        "zone_name" = "test1"
      },
    }
  }
}

output "test" {
  value = var.zones
}

Output ::

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Outputs:

test = {
  "test0" = {}
  "test1" = {}
}

When I remove the object from type map,

variable "zones" {
  type = map
  default = {
    "test0" = {
      "vpc_ids" = ["vpc-obscura", "vpc-blerg"],
      "force_destroy" = true,
      "tags" = {"test" = "true"}
    },
    "test1" = {
      "vpc_ids" = ["vpc-blerg"],
      "force_destroy" = true,
      "tags" = {
        "test" = "true",
        "zone_name" = "test1"
      },
    }
  }
}

output "test" {
  value = var.zones
}

I get the below output ::

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Outputs:

test = {
  "test0" = {
    "force_destroy" = true
    "tags" = {
      "test" = "true"
    }
    "vpc_ids" = [
      "vpc-obscura",
      "vpc-blerg",
    ]
  }
  "test1" = {
    "force_destroy" = true
    "tags" = {
      "test" = "true"
      "zone_name" = "test1"
    }
    "vpc_ids" = [
      "vpc-blerg",
    ]
  }
}

Hope this helps!

Hi @harshavmb,

As you’ve now seen, when converting a value to an object type, Terraform will produce a successful conversion as long as the given object has at least the attributes specified, and it will discard any additional attributes in order to make the result have the required type. In your case, the conversion is happening at the boundary of the module itself: the calling module provides a value for zones, and Terraform converts that to match your given type constraint in order to populate var.zones in your module.

The type object({}) is an object with no attributes at all, so Terraform can convert any object to that type but it will do so by discarding all of the attributes, which is indeed what the error message says is happening here:

| each.value is object with no attributes

If you were to inspect var.zones here, and if we were using the default value given in the declaration, we’d see that its value is:

{
  test0 = {}
  test1 = {}
}

…and so for_each over that will indeed set each.value to an empty object on every repetition, because that’s what this map contains.

Terraform object types are designed to enforce a particular structure so that the calling module’s value can be checked to match that structure, and so the best way to use an object type constraint is to enumerate all of the attributes you are expecting:

  type = map(
    object({
      vpc_ids       = set(string)
      force_destroy = bool
      tags          = map(string)
    })
  )

You mentioned that removing the type constraint altogether also let it work. That’s because the default type constraint is any, which asks Terraform to choose the type automatically based on the provided value. The chosen type for the default you showed here would actually be an object type rather than a map type, like this:

object({
  test0 = object({
    vpc_ids       = tuple(string, string)
    force_destroy = bool
    tags = object({
      test = string
    })
  })
  test1 = object({
    vpc_ids       = tuple(string)
    force_destroy = bool
    tags = object({
      test      = string
      zone_name = string
    })
  })
})

Because Terraform can automatically convert from tuple and object types to list/set and map types (as long as the values inside have homogeneous types), you can generally get away with that sort of automatic type selection, though you will lose the benefit of having Terraform automatically check the type of the value given by the caller and so if they get it wrong they will likely get a less helpful error from inside the implementation of the module.


At the time of writing the latest version of Terraform has an experimental feature Custom Validation Rules for variables, which allows expressing validation rules using normal expressions rather than just type constraints, and so it can represent more complex validation rules than the type system alone can define. For example:

variable "zones" {
  type = any

  validation {
    condition     = can(tomap(var.zones))
    error_message = "The \"zones\" value must be a map of objects."
  }

  validation {
    condition     = can([for v in var.zones : toset(v.vpc_ids)])
    error_message = "All \"zones\" objects must have \"vpc_ids\", a set of VPC ids."
  }

  validation {
    condition     = can([for v in var.zones : tobool(try(v.force_destroy, false))])
    error_message = "The \"zones\" objects' \"force_destroy\" must be boolean."
  }

  validation {
    condition     = can([for v in var.zones : tomap(try(v.tags, {}))])
    error_message = "The \"zones\" objects' \"tags\" must be maps."
  }
}

locals {
  # A normalized version of var.zones for convenient use without
  # lots of preconditions elsewhere in the configuration.
  zones = tomap({
    for k, v in var.zones : k => {
      vpc_ids       = toset(v.vpc_ids),
      force_destroy = tobool(try(v.force_destroy, false)),
      tags          = tomap(try(v.tags, {}))
    }
  })
}

The validation feature might change before it finally ships as non-experimental, but the idea of it is to retain the same benefit of checking the value in the calling module block, but to give some more flexibility in what values can be accepted. The validation blocks take the place of the type check, and the local.zones expression takes the place of the automatic type conversion that would normally be done, allowing you to customize its behavior to provide default values for sub-attributes, etc.

Although this feature isn’t ready to use in “real” configurations today (due to being experimental), I think it will be a nice answer to this question in the future once it has stablized.