How do you model complex, structured input variables with object types and optional attributes?

6 minadvancedterraformtypesvariables

Quick Answer

Terraform's type system supports structural types beyond primitives: `object({ name = string, port = number })` for a fixed-shape record, and collections of them like `list(object({...}))` or `map(object({...}))` for a variable number of structured entries (e.g., a list of ingress rules, each with its own port/protocol/cidr fields). The `optional(type, default)` modifier inside an object type lets specific attributes be omitted by the caller, falling back to a default rather than forcing every consumer to specify every field — this is what lets a module accept a rich, structured configuration object while keeping most of its fields genuinely optional, instead of requiring dozens of flat, individually-optional top-level variables.

Detailed Answer

Simple flat variables (a string here, a number there) don't scale once a module needs to accept genuinely structured input — a list of firewall rules, each with several fields, some of which are optional. Terraform's structural types and the optional() modifier exist precisely for this.

Object type — a fixed-shape record

variable "ingress_rule" {
  type = object({
    port        = number
    protocol    = string
    cidr_blocks = list(string)
  })
}

object({...}) describes a record with named, typed attributes — analogous to a struct/interface in a general-purpose language.

Collections of objects

variable "ingress_rules" {
  type = list(object({
    port        = number
    protocol    = string
    cidr_blocks = list(string)
  }))
  default = []
}

A list(object({...})) is exactly the shape used earlier for dynamic-block-driven security group rules — a variable number of structured entries, each with the same fields.

Optional attributes

variable "ingress_rules" {
  type = list(object({
    port        = number
    protocol    = optional(string, "tcp")
    cidr_blocks = optional(list(string), ["0.0.0.0/0"])
    description = optional(string)
  }))
}

optional(type, default) lets a caller omit protocol/cidr_blocks/description entirely for any given rule, falling back to the specified default (or null if no default is given, for description). Without this, every caller would be forced to specify every single field on every single object, even when the sensible default rarely changes.

Why this matters for module design

This is what lets a well-designed module expose one rich, structured variable (ingress_rules) instead of a dozen flat, individually-optional top-level variables trying to approximate the same structure (ingress_ports, ingress_protocols, ingress_cidrs, all as separate parallel lists that must stay the same length and are easy to accidentally misalign). Structured types with optional attributes keep a module's public interface both expressive and ergonomic — callers write only the fields that actually differ from the sensible default, while the module's implementation can rely on every field being present (with a concrete value) by the time it reaches resource blocks.

Related Resources