Skip to content
SRE & DevOpsDeep Dive Published Updated 9 min readViews unavailable

Terraform Module Input Contracts: Object Types, Optional Attributes, and Null

Design predictable module interfaces with precise object types, optional attributes, null handling, and validations that explain caller mistakes.

A Terraform module interface is a contract between the caller and the module. The type of each input determines which values are accepted, how Terraform converts them, and which assumptions the implementation can safely make. A vague interface can accept surprising values, hide mistakes through conversion, or force callers to provide settings that should have reasonable defaults.

Precise type constraints make configuration easier to review and refactor. They describe the shape of structured input, distinguish a single object from a map of objects, and let a module add optional fields without requiring every caller to change at once. Type constraints do not replace semantic validation: a string can have the right type and still be an invalid region, port, CIDR, or resource name.

Choose the collection type that matches the contract

Terraform has primitive types such as string, number, and bool, and collection or structural types such as list, set, map, tuple, and object. A list or set contains values of one element type. A tuple can contain elements with different types at fixed positions. A map has string keys and values of one element type. An object has named attributes whose types can differ.

Choose a list when order and duplicate entries are meaningful. Choose a set when order is irrelevant and duplicates should collapse. Choose a map when callers need string keys and a uniform value type. Choose an object when a value represents a record with a known set of named fields. Avoid using a tuple to model a record merely because a positional literal seems shorter; position is harder for reviewers and consumers to understand than a named attribute.

variable "service" {
  description = "Deployment settings for one service."

  type = object({
    name          = string
    instance_type = string
    subnet_ids    = set(string)
    tags          = map(string)
  })
}

This declaration makes the required shape visible to callers and editor tooling. A value with a number where a string is required is a type error unless Terraform can convert it according to its type conversion rules. Do not rely on automatic conversion to define business intent. A number converted to text may be syntactically accepted while still representing an invalid configuration value.

Optional object attributes and defaults

Object attributes are required unless the type uses the optional modifier. An optional attribute without an explicit default has a null value when the caller omits it. An optional attribute with a default receives that default when the caller omits it or passes null. This distinction can eliminate repeated null checks inside a module when the default is genuinely safe.

variable "service" {
  type = object({
    name          = string
    instance_type = string
    disk_gb       = optional(number, 40)
    monitoring    = optional(bool, true)
    description   = optional(string)
  })
}

Here disk_gb and monitoring are non-null inside the receiving module because they have non-null defaults. description remains nullable when omitted. Do not add a default solely to silence a null expression. Choose a value that matches the module’s documented operational behavior, and be clear about whether a caller may intentionally disable a feature.

Optional defaults in nested object types are applied from top to bottom. If an optional parent object has a default object, Terraform then applies that object’s nested attribute defaults. This makes nested configuration compact but can surprise consumers if a parent default silently activates behavior. Document defaults that create billable resources, expose a service, or change retention.

Null, omission, and nullable variables

An omitted input variable and a value explicitly set to null can behave differently depending on the variable’s default and type constraints. At the top-level variable boundary, Terraform can use a declared default when the caller omits the variable. If the caller explicitly supplies null, the default is normally used unless the variable allows null. For object attributes, optional defaults replace an omitted or null attribute; an optional attribute without a non-null default remains null.

Use nullable = false on a variable only when null is not meaningful for that entire input. It does not recursively make every nested attribute non-null. For nested objects, express which attributes are optional and supply defaults only where the module can safely determine behavior. Avoid sentinel strings such as “none” when null already represents absence; sentinels leak into downstream expressions and can accidentally be treated as real values.

variable "replica" {
  type = object({
    bucket_name = string
  })
  default     = null
  nullable    = true
  description = "Optional settings for a replica bucket."
}

resource "aws_s3_bucket" "replica" {
  count  = var.replica == null ? 0 : 1
  bucket = var.replica.bucket_name
}

The example treats the optional replica object as the presence switch. In a real module, add the corresponding provider configuration and validate dependent inputs together; consider for_each with a stable key when the optional object represents a named resource. A nullable type is not automatically a feature flag; state what null means and make the resulting plan easy to inspect.

Object conversion can discard attributes

Terraform can convert a larger object value to a narrower object type when the required attributes are compatible. Attributes that are not declared in the receiving object constraint can be discarded during conversion. That can make a module interface appear to accept a field while the child module never receives or uses it.

Treat object constraints as explicit schemas. When a new field is added, update the type constraint, defaults, validation, documentation, and tests at the same time. Avoid passing a broad decoded configuration object through several modules and hoping every nested layer preserves unknown fields. If the interface must preserve arbitrary keys, use a map of a specific value type or a deliberately generic pass-through boundary, then validate the portions that the module interprets.

The special type keyword any is not a concrete type that accepts any value forever. It tells Terraform to infer a suitable type from the value in a context that can support multiple types. Use it sparingly, primarily when a module passes a value through without inspecting its structure. If the module reads named fields, arithmetic values, or collection elements, declare the actual structure so failures occur at the module boundary rather than deep in an expression.

Separate type checking from semantic validation

Type constraints answer questions such as whether a value is a string or whether an object contains a required attribute. They do not establish that the string names an allowed environment or that a number falls within an operational limit. Add variable validation for those semantics, with messages that explain the accepted range or format.

variable "service_port" {
  type        = number
  description = "TCP port used by the service."

  validation {
    condition     = var.service_port >= 1 && var.service_port <= 65535
    error_message = "service_port must be a number from 1 through 65535."
  }
}

Validation should be local to the inputs it constrains and should not turn a module into an opaque policy engine. If the rule depends on provider-returned data, use an appropriate precondition or postcondition instead. If the rule is an organization-wide deployment requirement, enforce it in the policy layer as well as documenting the module-level contract.

Use type constraints and validation together for structured values. A list of objects can enforce that each item has a name and a region, while a validation expression can enforce unique names or reject incompatible combinations. Keep expressions readable. A deeply nested validation with several nested comprehensions is harder to maintain than a small normalization local followed by a clear condition.

Make interface evolution compatible

Adding a required attribute to a published module input is a breaking change because every caller must supply it. Adding an optional attribute with a safe default is often compatible, but still can alter behavior if the default provisions new infrastructure. Renaming an attribute is breaking unless the module supports a migration period with an old and new input and a clearly defined precedence rule.

When changing an interface, consider a staged approach: add an optional field and document it, update callers gradually, add validation for combinations that are no longer supported, then remove the obsolete field in a major module release. Avoid accepting two aliases indefinitely if their precedence is ambiguous. Tests should cover omitted, null, defaulted, and explicitly overridden values so compatibility remains visible.

Input variables are not the only public contract. Outputs have types inferred from their expressions, and callers can depend on those shapes. Keep output objects stable where consumers use them, avoid exposing provider internals that are not part of the intended abstraction, and add tests that verify representative output values. Version module releases according to the impact of interface changes rather than the size of the source diff.

Test the contract from a caller’s perspective

Use a small set of tests that exercise the module as a consumer would. Include the minimal valid object, a value that overrides each important default, a null or omitted optional attribute, and an invalid value that should fail early. For nested defaults, inspect the resulting plan or output so the suite proves what the caller actually gets.

Mock providers are useful when module tests need to inspect resource arguments without cloud credentials. They do not prove that the provider accepts every value or that the remote service behaves as expected. Pair unit-style tests for interface logic with a controlled integration plan for provider-specific behavior, and keep any real apply test isolated from production state.

When debugging an error, distinguish a type-conversion error from a semantic validation error. A type error generally means the caller supplied a value with the wrong shape. A validation error means the shape was accepted but violated a documented rule. Clear boundaries give maintainers a better message to fix and prevent implementation expressions from becoming the first place that malformed input is detected.

Well-designed module inputs make the allowed configuration explicit and the resulting plan explainable. Use objects for named records, optional attributes for compatible evolution, null only when absence has defined meaning, and validation for domain rules. That gives callers flexibility without sacrificing a stable contract.

Related:

Sources:

Comments