Terraform 0.12 gives reusable modules structured interfaces
Terraform 0.12 makes expressions and nested types central to configuration, with consequences for module design, unknown values, and upgrade compatibility.
Reusable infrastructure modules need to exchange more than strings with agreed meanings. A module that accepts information about a network should be able to describe the fields it needs. Its caller should be able to pass a structured value without flattening that information into unrelated parameters. Terraform 0.12 makes this kind of interface a normal part of the language.
The May 22 release describes a language overhaul shaped by experience with earlier versions. First-class expressions receive much of the attention because their effect is immediately visible. An assignment can say ami = var.ami without a quoted interpolation wrapper. The deeper change is that expressions can exchange richer values with defined types throughout configuration.
I value Terraform for putting a reusable language over infrastructure APIs. This release strengthens the reusable part. A module can describe the structure of what it accepts, and the language can preserve that structure while values move between resources and modules.
A module can ask for an object
The type constraints documentation at the release tag distinguishes collections from structural types. A collection groups values with one element type. An object describes named attributes that can have different types. A tuple describes a fixed sequence whose positions can also have different types.
These distinctions matter when writing a module interface. A map of strings is suitable when all entries have the same role and representation. An object is suitable when specific names identify different parts of an input. A list of objects can represent a repeated structure without giving up the types inside each element.
For example, a module can declare a network input with two required string attributes. This is an illustrative interface using the syntax documented in 0.12.
variable "network" {
type = object({
id = string
cidr_block = string
})
}
output "network_id" {
value = var.network.id
}
The object constraint requires the named attributes. A larger value can still satisfy the constraint, and the conversion discards attributes that the receiving object type does not declare. The documentation gives an analogous example using a VPC resource object, whose additional provider attributes do not need to become part of the receiving module's interface.
That behavior allows a module to specify the information it consumes without demanding that the caller first construct an exact copy. It also limits what the child module can assume. Passing a larger resource object does not silently turn every attribute of that resource into a declared input field.
The input variable documentation recommends type constraints because they document expectations and allow useful errors when inputs have the wrong type. This is stronger than naming a variable network_settings and leaving every consumer to infer its shape. It remains a type contract, so it does not establish that a string contains a valid remote identifier or a network range permitted by a particular API.
Resource references become values
The release also allows an entire resource or module to be treated as an object value. A reference such as aws_vpc.example can supply a complete object to an expression. The receiving context can then select or constrain its attributes.
This connects resource schemas to reusable interfaces. The provider describes the resource's attributes, Terraform represents the resource as a value, and a module's input type states the subset it needs. The configuration author does not have to encode that relationship as a separate convention for every scalar field.
There are limits to this generalization. Resource instance selection still matters. The upgrade guide explains that a resource with count becomes a list of objects even when its count is one. Selecting an individual instance is therefore a separate step from reading an attribute of that instance. The uncounted resource and a collection containing one resource have different shapes.
That difference is useful information. Code that accepts a network object should not have to guess whether it received one network or a list of possible networks. When an interface requires one object, the caller chooses the instance it intends to supply.
Transformations operate on collections
The release introduces for expressions for constructing lists and maps by transforming and filtering existing collections. This puts a common configuration operation into the expression language. A module can derive a value from structured input without encoding the transformation as a string template or relying on a provider to perform local data manipulation.
For example, this configuration filters a list of service objects and returns the names of the enabled entries. The input shape and the result follow the release's variable and expression syntax.
variable "services" {
type = list(object({
name = string
enabled = bool
}))
}
output "enabled_service_names" {
value = [for service in var.services : service.name if service.enabled]
}
The result is a value. A dynamic block performs a related but different job by generating nested configuration blocks accepted by a provider schema. Confusing those operations makes modules harder to read. A for expression computes data, while a dynamic block supplies repeated parts of configuration where the surrounding block type expects them.
The versioned expression documentation also limits what dynamic blocks can generate. They cannot generate meta-argument blocks such as lifecycle because Terraform needs to process those before it can safely evaluate the expressions. The richer language therefore retains an evaluation order imposed by infrastructure planning.
Unknown values retain useful information
A plan frequently refers to values that do not exist yet. An API may assign an identifier only after Terraform creates the remote object. The expression documentation describes unknown value placeholders that let evaluation continue during planning. Operations involving an unknown input generally produce an unknown result.
Version 0.12 improves the precision of this representation. The release notes explain that adding a new counted instance no longer makes every existing identifier in a splat result appear unknown. Terraform can keep known elements while representing only the new identifier as unknown. It also tracks the type of an unknown value.
This distinction reduces an older source of misleading type errors. A list containing an unknown element is still a list. Earlier behavior could lose that information and complain that the result should be a list, leading authors to add redundant list brackets. In the new language those brackets really create another level of nesting, so the workaround becomes a different value.
Unknown values still constrain planning. The documentation requires resource count to be known during the plan because Terraform must determine how many instances the configuration declares. Typed unknowns improve what Terraform can represent, but they do not let it postpone every structural decision until apply.
I find this an important qualification to the stronger type system. Types help Terraform reason about values it has not obtained yet, but a useful plan still needs some facts in advance. A module interface can be valid while a particular use of its value remains too early to evaluate.
Omission is distinct from a default string
The new null value gives configuration a way to omit an argument conditionally. The release says assigning null is equivalent to leaving the argument out. A default can then apply, or a required argument can produce an error. Null is therefore not a universal escape from required configuration.
This is useful when wrapping provider resources in modules. Previously a module might have to choose and repeat a specific fallback value even when the intended behavior was to use the underlying default. With null, the interface can represent that intention directly. The provider's defaults remain relevant, so the module's documentation still needs to explain the resulting behavior.
Conditional expressions also improve. The release notes say they support results of any type and only return evaluation errors from the selected result expression. Combined with structured values, this makes conditional configuration less dependent on the older string interpolation model.
Compatibility extends beyond source files
The 0.12 upgrade guide starts with preparation in 0.11.14, including its temporary 0.12checklist command. The guide separates provider compatibility from the later configuration conversion. Compatible provider releases matter because the plugin wire protocol changes in 0.12, and older plugin binaries cannot communicate using the new protocol.
The automatic upgrade command handles many source changes, but it cannot infer every intention behind a workaround. The release also changes persisted state and saved plan formats. Tools that parse those files need corresponding updates, and HashiCorp recommends terraform show -json for many integrations instead of direct parsing of internal artifacts.
Another compatibility change affects variable delivery. Terraform checks names supplied through -var and variable files against declared inputs. Automation that intentionally supplies a broad set of possible inputs can use TF_VAR_ environment variables, which Terraform ignores when the matching variable is undeclared. The distinction lets explicit configuration catch mistakes without requiring every environment to contain exactly the same variables.
For reusable modules, the result is a more precise contract with a real version boundary. Structured inputs describe what a module accepts, and object outputs preserve related information for callers. Adopting those interfaces requires compatible plugins and callers that understand the new value shapes, alongside the converted configuration files.