September 21, 2022·5 min read·engineeringterraformmodulestypes

Terraform 1.3 gives object attributes their own defaults

Optional object attributes become stable with defaults declared in the type constraint, replacing the earlier experimental defaults function.

A reusable module can accept a structured object without requiring every caller to repeat every setting. Terraform 1.3, released on September 21, stabilizes optional attributes in object type constraints and lets each attribute declare its own default. The final design places the fallback beside the attribute's type.

The release concludes the optional-attribute experiment that began in Terraform 0.14. Its earlier defaults function does not graduate into the stable language. Instead, optional accepts a second argument that supplies the default directly in the object declaration. Experimental modules must remove the opt-in and adopt that syntax.

I like this direction because a module's accepted structure and its fallback behavior belong together. A reader examining the type constraint can see which attributes callers must provide and which decisions the module owns. This makes a structured interface more useful without requiring the interface to become untyped.

Omission becomes a defined value

The type-constraint reference included in 1.3.0 describes the rule precisely. Terraform normally reports an error when a required object attribute is missing. Marking the attribute optional makes Terraform insert a default instead. Without an explicit second argument, that default is a null value of the attribute's type.

The following variable separates a required name from two optional settings. The retry count has a non-null default, while the description remains nullable.

terraform {
  required_version = ">= 1.3.0"
}

variable "service" {
  type = object({
    name        = string
    retries     = optional(number, 3)
    description = optional(string)
  })
}

A caller can supply only the name. The receiving module then gets the retry count defined by the constraint and a null description. The object has a consistent set of attributes inside the module even though the caller omitted some of them.

A non-null default also applies when the caller explicitly assigns null. This is stronger than merely accepting a missing key. The reference guarantees that an optional attribute with a non-null default will not be null inside the receiving module. That removes the need to repeat null-handling expressions at each use of that attribute.

The choice of fallback therefore affects the interface. A null default preserves an absence that the module must interpret. A concrete default resolves absence into a particular value. Module authors should choose according to the meaning they intend for callers, because those behaviors are observably different.

Defaults proceed through nested structure

Nested objects make the evaluation order more significant. Terraform applies attribute defaults from the outside inward. It first selects the enclosing attribute's default and then applies defaults for attributes inside the resulting object. An empty object can consequently ask Terraform to populate the inner structure.

This example makes a nested settings object optional while giving its members independent defaults. The declaration is a module input, so the surrounding configuration can refer to the completed value through var.settings.

variable "settings" {
  type = object({
    logging = optional(object({
      enabled = optional(bool, true)
      format  = optional(string, "json")
    }), {})
  })
}

Supplying an empty settings object causes the omitted logging attribute to receive its empty-object default. Terraform then fills the inner attributes. Supplying a logging object with an explicit format preserves that format and still fills the omitted enabled flag. This follows the top-down behavior documented for nested optional attributes.

The empty-object default is doing real work. It supplies an object to which inner defaults can apply. Without a non-null default for the enclosing optional attribute, omission can remain a null object instead of becoming a populated settings value. The type definition should make that difference deliberate.

This arrangement also lets different callers specify different subsets of one structure. A caller that cares only about one member can leave the others to the module. The resulting object inside the module still follows the declared types and fallback rules, which is useful when one shared definition supports several configurations.

Conditional omission has a concise expression

The versioned documentation shows a caller using a conditional expression with null in one branch. When the condition selects null for an attribute with a non-null default, the receiving module substitutes its fallback. The caller can therefore express an exceptional override without copying the normal value.

For example, a caller might pass a legacy filename only when a compatibility option is enabled. Otherwise it passes null and leaves the normal filename to the module's declared default. The calling configuration records the exceptional policy, and the module remains the place where the ordinary setting is defined.

This reduces duplication in an important way. Copying a default into every caller makes a module's later default change ineffective for those callers. Leaving the attribute unspecified, including through the documented null behavior, preserves the caller's decision to accept the module's fallback.

I prefer that explicit delegation to a collection of normalization expressions scattered through resource arguments. The constraint now tells the reader which values Terraform inserts before the module uses the object. The module can spend more of its configuration describing infrastructure and less describing how to reconstruct its own input shape.

Migration includes a version requirement

A module using the stable syntax should declare Terraform 1.3.0 or later, as the release notes recommend. That requirement gives callers on older versions a direct compatibility message. Retaining the experimental opt-in does not preserve the final design, because the stable feature replaces the experimental defaults mechanism.

The move also changes what reviewers should look for in a module interface. Required attributes describe information the caller must supply. Optional attributes with null defaults preserve absence, while non-null defaults establish the values the receiving module will use. Nested defaults then apply in the documented order.

The default must also be compatible with the attribute type. Declaring a numeric attribute does not create permission to supply an arbitrary fallback expression of another shape. The optional modifier still participates in a type constraint, so the fallback and the accepted input must describe a consistent interface.

Those distinctions are enough to explain the behavior without treating every object as a loosely typed map. Terraform 1.3 keeps the structure explicit and gives omission a meaning at each attribute. A caller can provide a partial configuration, and the receiving module obtains the completed object according to the type declaration.