Request for Feedback: Optional object type attributes with defaults in v1.3 alpha

The behaviour of this new feature is exactly what I need. I have started targeting 1.3 for all my development work.

Thank you for this comprehensive answer.

Hi,

I’ve been experimenting with the previous defaults() function and came across the issue below so after reading through I have abandoned my experiment

This new functionality seems to solve most of the complaints in the GitHub issue.

One scenario I have that does not work with the “new” approach is like below where I have dynamic optional values

scaling : object({
    enabled : optional(bool, local.scaling_enabled)
    min_replicas : optional(number, 1)
    max_replicas : optional(number, 5)
})

This example is simplified - my real type has multiple levels of optional nesting and a bit of conditional logic to set local.scaling_enabled.

If the defaults() function had worked with nested objects it would have been a good solution for this scenario.

Are there any plans to support var or local in the optional attribute?

Hi @danoliver1,

Unfortunately I don’t think it will be feasible to support dynamic defaults because Terraform tends to need to resolve default values early on during validation and type conversion, rather than at runtime in the actual plan and apply operations.

I think that would be a situation where you would still need a function like defaults. Although that particular function didn’t survive into the final release, the design of that function was essentially an extension of the existing coalesce function to try to apply it to entire data structures at once, and so you could potentially use coalesce for enabled specifically in order to get an equivalent result:

coalesce(var.something.scaling.enabled, local.scaling_enabled)

If enabled is null then coalesce will return local.scaling_enabled instead.

I expect we’ll try to improve on this situation in later releases, but I can’t say yet exactly what the design will be. We know from feedback that the defaults function was very confusing to use, so I don’t expect we’ll try to bring that back again, but we do still need to find some way that involves normal expressions evaluated at runtime, rather than static values embedded inside the type constraints.

Fortunately if we do find a suitable design later then I expect it will end up being backward-compatible with what we’ve stablized for v1.3, and so answering that question doesn’t need to be a blocker for concluding the experiment.

Is there a nullable equivalent that works for object attributes?

Hi @lorengordon,

There is not a mechanism exactly equivalent to “nullable”, because nullable was a compromise we were forced to take in order to remain backward-compatible with modules written before it existed.

Specifically, there is one case that has no equivalent in optional attributes: distinguishing between a caller explicitly assigning null and a caller just omitting an optional argument entirely. We consider it a historical design error that it was possible to distinguish those cases; assigning a null value to an optional input variable that specified a default should’ve caused the final value to be that default. nullable = false effectively opts in to that correct behavior, and disables the incorrectly-designed behavior.

For optional attributes, there is intentionally no way to distinguish those situations, because there is no regrettable existing behavior to be backward compatible with in this case; the caller explicitly assigning null is exactly equivalent to omitting the attribute altogether, which matches how arguments in resource blocks work. If the optional attribute has no default value then in both cases the attribute’s value will be null, and if there is a default then in both cases the attribute’s value will be the specified default value.

To say that in more concrete terms, the optional attribtues syntax allows you to specify behavior equivalent to the following two situations:

# This one is equivalent to an optional
# attribute with a default value:
#    a = optional(string, "foo")
variable "a" {
  type    = string
  default  = "foo"
  nullable = false
}

# This one is equivalent to an optional
# attribute without a default value:
#    b = optional(string)
variable "b" {
  type    = string
  default = null
  # nullable = true is the default
}

# There is no equivalent to the following
# for an optional attribute, because this
# will produce "foo" if c is omitted, and
# null if the caller writes c = null .
variable "c" {
  type    = string
  default = "foo"
  # nullable = true is the default
}

# And for completeness, this last combination
# isn't valid for either variables or optional
# attributes:
variable "d" {
  type    = string
  default  = null
  nullable = false
  # INVALID because default = null contradicts
  # nullable = false .
}

Thanks @apparentlymart! I don’t think I had seen that spelled out explicitly, so I was operating under the assumption that it worked just like variables. In other words, that the user could set null explicitly, and that null would then be the resulting value, even if the variable had a default value.

Naturally, I’m probably one of those that will run into this as an edge case, as I seem to have a perverse knack for inadvertently finding these seams and taking advantage of them! :joy:

Martin’s post above is not completely correct: assigning null is actually treated differently from omitting the attribute altogether.

Your assumption was correct: this is how the feature works at present. Consider this configuration:

variable "foo" {
  type = object({
    a = optional(string, "hello"),
  })
}

output "foo" {
  value = var.foo
}

Omitting the a attribute here causes Terraform to apply the specified default value:

$ terraform plan -var 'foo={}'

Changes to Outputs:
  + foo = {
      + a = "hello"
    }

Whereas supplying an explicit null value causes Terraform to pass it through:

$ terraform plan -var 'foo={ a = null }'

Changes to Outputs:
  + foo = {
      + a = null
    }

This behaviour is intuitive to me, but clearly not so for everyone. I’d be interested to hear your thoughts, @lorengordon, as well as any feedback others may have.

Indeed, sorry for the misinformation above, everyone! This is a danger of extrapolating from details of earlier phases of the design without checking my work first.

You can see from this that we’re also not entirely sure what the best tradeoff is between flexibility (e.g. being able to give special meaning to the distinction between unset and null) and convenience (e.g. allowing module authors to concisely express that something should never be null and be freed from using coalesce inline), so I too would love to hear from you all about which of these designs would be most useful in the modules you’re hoping to use optional attributes in.

Thanks again, both @alisdair and @apparentlymart! I also think the current behavior is intuitive. But that may be because it matches the current behavior of variables, and I have a little too much experience with terraform to be entirely objective about what is intuitive or not. And I also tend towards enabling advanced use cases over always keeping things simple, anyways.

I do rather like having the option of the current behavior, as it does allow me to set a default, but also create a special condition when the value is null and do something else.

But that does open up the nullable question again, as I am sure someone will want to prevent this behavior. Perhaps a third argument to optional(<type>, <default>, <nullable>)?

I must admit I do feel loathe to replicate the “we don’t know so I guess we’ll allow both” path that we ended up on with input variables, but if we can see benefits to either side that outweigh the considerable complexity in there being two possible modes for users to understand and decide between then that is indeed a possible third option.

If we do make it something the module author gets to decide then I would suggest making it be non-nullable by default and have nullability be the explicit opt-in, even though that is the opposite of how variables work, because when we added nullable = false for variables we did so with the intention that false would become the default value for that argument in a later language edition, assuming we eventually accumulate enough language design regrets to justify one.

I suppose that while I am greatly looking forward to this optional() function in tf 1.3, I still wish objects might be implemented in a way where they are just “nested variables” such that each attribute supports all the same arguments as the variable block.

variable "foo" {
  type = object({
    variable "a" {
      type        = string
      default     = "hello"
      description = "just a string"
      nullable    = false
    }
  })
}

Yeah, it’s super verbose, but it’s complete. And having descriptions is great for documentation.

Hi again all!

As you can see in the chatter above, @lorengordon’s question about our handling of null values made us realize that our team didn’t have a shared understanding of what we’d designed and what was implemented.

This is a great example of why we like to share work in progress in these alpha releases and react to your questions and feedback, so thanks for drawing attention to that!

Since then we’ve been reviewing some of the use-cases that motivated this feature, and reviewing similar behaviors elsewhere in Terraform. Based on that we’ve made a small design change in today’s v1.3.0-alpha20220817 release which will make Terraform treat a null value in the way I described in my earlier incorrect comment.

Specifically, if an attribute has a default value specified then Terraform will use that default value both when the attribute is entirely omitted by the caller and when the caller explicitly sets it to null.

We’ve made this change to support the established patterns for dynamically deciding to leave an optional attribute unset. The simplest form of that is a conditional expression where one of the result arms is null:

  example = {
    foo = var.predicate ? "foo" : null
  }

There are other similar situations that don’t involve conditionals, but the above is the most common technique.

The general idea is to make the two mechanisms involved here work independently of one another:

  • An optional attribute means that Terraform will synthesize a null attribute of the appropriate type when converting a source object that omits that attribute.
  • Specifying a default means that Terraform will replace a null value with the given default value after type conversion is complete, regardless of why the attribute turned out to be null.

This then matches how Terraform treats optional arguments in resource blocks: Terraform Core handles the conversion to the given object type and then the provider replaces any null value with its default value.


We’ve done our best to try to identify potential real-world situations where it would be important to distinguish an explicit null value from an null value implied by omission, but we didn’t find any compelling examples in our analysis so far.

We’d love to hear from you if you try the new behavior in the latest alpha and find any specific real-world situations where being unable to recognize this difference is problematic.

In response to any examples we will first try to imagine a different way for you to model the situation using this updated design. If we cannot find a reasonable answer then we still have some time to adjust the design further before including it in the final v1.3 release, or to convince ourselves that the need could be addressed by a backward-compatible addition in a later release.

This comment is already far too long so I won’t go into the details here, but we do have sketches for some different ways to extend the attribute declaration syntax with more features in a backward-compatible way, and so the door is still open for more detailed constraints in later versions.

Thanks again for trying this out and sharing your feedback!

Hmm… Personally, I feel like I end up using explicit null checks in modules all the time, to condition a resource or dynamic block. So my hot take, this feels undesirable, especially without a nullable equivalent. Workaround would require yet another attribute/argument, so the value remains separately testable. If I didn’t want null passed in at all, I think I would just set a validation condition, instead?

Hi @lorengordon,

Can you share a specific example of what you mean?

Note that this doesn’t prevent you from using optional without a default in order to let a plain null show through and make checks based on that. It only prevents you from writing logic that differentiates between the caller explicitly writing null vs. omitting the optional attribute altogether, because we intend for callers to understand those as exactly equivalent in the same way they are in resource blocks.

It’s a little hard to come up with an exact example because I don’t use experimental features. I have examples where I have an object attribute that I test explicitly for null, but I don’t bother setting a default for the entire object, and can’t yet set a default for just the attribute.

I do have an example where we use a condition on the variable instead of the object attribute. We set a default value, and also test whether the user passed null, using null as the condition to skip creation. This works because nullable = true by default. Without that, which is basically the change in question for object attributes, I don’t think this would work.

resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
  count = var.server_side_encryption_configuration == null ? 0 : 1

  bucket = aws_s3_bucket.this.id

  rule {
    bucket_key_enabled = var.server_side_encryption_configuration.bucket_key_enabled

    apply_server_side_encryption_by_default {
      sse_algorithm     = var.server_side_encryption_configuration.sse_algorithm
      kms_master_key_id = var.server_side_encryption_configuration.kms_master_key_id
    }
  }
}

variable "server_side_encryption_configuration" {
  description = "Schema object of the server side encryption configuration"
  type = object({
    bucket_key_enabled = bool
    kms_master_key_id  = string
    sse_algorithm      = string
  })
  default = {
    bucket_key_enabled = true
    kms_master_key_id  = null
    sse_algorithm      = "aws:kms"
  }
}

Hi @lorengordon,

I think what you are saying is that it isn’t clear how you would reproduce this server_side_encryption_configuration variable if it were a sub-attribute of another variable, rather than a top-level attribute itself.

To try to turn that into an example using these new features, I’m going to just guess that it might make sense for this variable to appear as a nested object inside a more general variable called s3, declared like this:

variable "s3" {
  type = object({
    server_side_encryption = object({
      bucket_key_enabled = bool
      kms_master_key_id  = string
      sse_algorithm      = string
    })
  })
}

From studying your example I think you are saying that the important detail here is that you want server_side_encryption_configuration to be enabled by default, but you want to allow users of the module to explicitly turn it off by setting it to null, thereby overriding the default value to be null instead.

If so, then indeed that would not be possible under the new design here. Under the rules that we have for the rest of Terraform (ignoring the accidental special case for input variables), it would be necessary to use an explicit boolean value, or some other non-null value, to represent affirmatively disabling something.

Given that, an optional object types version of this design (and also a nullable = false version of this design) would need to follow the same design patterns a provider developer would follow when modelling something like this, which is to add an explicit enabled attribute:

variable "s3" {
  type = object({
    server_side_encryption = optional(
      object({
        enabled            = optional(bool, true)
        bucket_key_enabled = optional(bool)
        kms_master_key_id  = optional(string)
        sse_algorithm      = optional(string)
      }),
      {
        bucket_key_enabled = true
        sse_algorithm      = "aws:kms"
      },
    )
  })

  validation {
    # If enabled = false then the other options are forbidden
    condition = (
      !var.s3.server_side_encryption.enabled ? (
        var.s3.server_side_encryption.bucket_key_enabled == null &&
        var.s3.server_side_encryption.kms_master_key_id == null &&
        var.s3.server_side_encryption.sse_algorithm == null
      ) : true
    )
    error_message = "Cannot set .server_side_encryption settings when enabled = false."
  }

  validation {
    # If enabled = true then some other options are required
    condition = (
      var.s3.server_side_encryption.enabled ? (
        var.s3.server_side_encryption.sse_algorithm != null
      ) : true
    )
    error_message = "Must set .server_side_encryption.sse_algorithm when enabled = true."
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
  count = var.server_side_encryption_configuration.enabled ? 1 : 0

  bucket = aws_s3_bucket.this.id

  rule {
    bucket_key_enabled = coalesce(var.server_side_encryption_configuration.bucket_key_enabled, true)

    apply_server_side_encryption_by_default {
      sse_algorithm     = var.server_side_encryption_configuration.sse_algorithm
      kms_master_key_id = var.server_side_encryption_configuration.kms_master_key_id
    }
  }
}

It’s notable that for the equivalent of this design in a provider schema the validation blocks in my example above would be written as logic in the provider’s implementation language (typically Go) rather than as expressions in the Terraform language:

    if sse.enabled {
        if sse.bucket_key_enabled != nil || sse.kms_master_key != nil || sse.sse_algorithm != nil {
            // (return an error)
        }
    } else {
        if sse.sse_algorithm == nil {
            // (return an error)
        }
    }

So while this is essentially functionally equivalent to the corresponding design pattern for providers, I think it’s debatable how the readability/maintainability of it compares, given that Terraform module authors aren’t typically expected to deal with complex boolean expressions like those, whereas they are more common in a general-purpose programming language.


With that all said, I think it’s worth noting that the original design of treating null as special (without nullable = false set) does prevent the conditional-setting pattern that this design change was intended to enable.

Here’s a hypothetical module call using the variable as @lorengordon originally presented it, rather than my modified example:

module "s3" {
  # ...

  server_side_encryption = (
    var.kms_master_key != null ?
    {
      kms_master_key = var.kms_master_key
    } :
    ???
}

The ??? placeholder in the above example represents “what can I write here to dynamically select the default value that the module author specified for this variable?” null would be our answer when nullable = false, but when nullable = true (the current default, arguably a mistake) the only way to get the default is to not specify the server_side_encryption argument at all, which prevents making a dynamic decision about it.

@apparentlymart I think you got it exactly.

And yes, for this use case we could use a new attribute specifically for the condition, instead of testing the entire object for null. It seems extraneous to me and requires more from the user, but that’s what I was trying to get at earlier:

Workaround would require yet another attribute/argument, so the value remains separately testable.

Thanks for confirming, @lorengordon!

The explicit extra argument is in line with our design expectations elsewhere in Terraform, so I expect that we will move forward with that as the recommendation for now and then in later releases we will consider possible improvements to the ergonomics of that design pattern, so that it’s possible to achieve an interface like what I showed without so much boilerplate.

The typical rule in the Terraform language is that null represents the absense of a value rather than being treated as a value in its own right, similar to its role in SQL. In other words, null is intended to represent “I have nothing to say about this attribute” rather than “I want to explicitly disable this attribute”. Given all of the existing design precedent in that direction and that input variables behaving differently was an accidental design mistake, I expect that our direction here will be towards making it more convenient to employ explicit mechanisms for disabling rather than towards making it easier to overload null to represent disabling in certain cases.

Reading through the examples provided in response to my use case, I am also struggling to see how the module author might set their own default, and also allow the user to pass null.

In this case, when the user passes null, it is intended to represent the absence of something, so they get the default implemented by the resource provider, instead of the default implemented by the module author.


Here is the design case equivalent to nullable = true. If the user passes null explicitly for any object attribute (say, bucket_key_enabled = null) then that value gets passed to the underlying resource argument and picks up the default value from the provider. Clean and easy.

variable "s3" {
  type = object({
    server_side_encryption = optional(
      object({
        bucket_key_enabled = optional(bool, true)
        kms_master_key_id  = optional(string)
        sse_algorithm      = optional(string, "aws:kms")
      }),
      {
        bucket_key_enabled = true
        sse_algorithm      = "aws:kms"
      },
    })
  )
}

resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
  count = var.s3.server_side_encryption_configuration == null ? 0 : 1

  bucket = aws_s3_bucket.this.id

  rule {
    bucket_key_enabled = var.s3.server_side_encryption_configuration.bucket_key_enabled

    apply_server_side_encryption_by_default {
      sse_algorithm     = var.s3.server_side_encryption_configuration.sse_algorithm
      kms_master_key_id = var.s3.server_side_encryption_configuration.kms_master_key_id
    }
  }
}

Now I’m trying to imagine how to do the same for the design equivalent to nullable = false… I actually don’t think it’s possible? If the user ever passes null then the module-level default is assigned instead of the resource-level default, right? What am I missing? Is there another design pattern we’re supposed to be using?

variable "s3" {
  type = object({
    server_side_encryption = optional(
      object({
        enabled            = optional(bool, true)
        bucket_key_enabled = optional(bool, true)
        kms_master_key_id  = optional(string)
        sse_algorithm      = optional(string, "aws:kms")
      }),
      {
        bucket_key_enabled = true
        sse_algorithm      = "aws:kms"
      },
    )
  })
}

resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
  count = var.s3.server_side_encryption_configuration.enabled ? 1 : 0

  bucket = aws_s3_bucket.this.id

  rule {
    bucket_key_enabled = ???  # How do I get the `null` from the user input?

    apply_server_side_encryption_by_default {
      sse_algorithm     = var.s3.server_side_encryption_configuration.sse_algorithm
      kms_master_key_id = var.s3.server_side_encryption_configuration.kms_master_key_id
    }
  }
}

(Note the ??? in the bucket_key_enabled expression. I am looking for guidance there.)