The HCL Type System — the honest interface
A module input without a type is a rumor about what the module wants.
optional(type) yields null and passes the hole downstream; optional(type, default) fills it at the border. Know which promise each field makes.§ IFrame
The 07-25 HCL lesson made the module an API: for_each fanning one block into a fleet, dynamic blocks folding structure out of data, the expression language doing the computing. Power was the subject. Honesty is today's.
An API is a promise about what the caller may send and what the callee will do. In most languages the signature carries that promise and a checker enforces it. HCL has the same machinery, and most Terraform in the wild ignores it: variables with no type, objects passed as loose maps, invalid values discovered by the cloud provider twenty minutes into an apply. The typed variable block is where the promise lives. Call it the honest interface: the block that tells the caller the whole truth about shape, defaults, and legality, and backs it with plan-time enforcement.
Today's paired Ops lesson gives the economics: in the pipeline, validate and plan run on the pull request, in seconds, with a read-only role. Every promise the interface states in types is a failure moved from the gated apply to the cheap gate. Types are where failure goes to get cheaper.
§ IILanguage Idiom — The Constraint Lexicon
HCL's types come in three families. Primitives: string, number, bool. Collections, many of one type: list(string), set(string), map(number). Structural, named fields of differing types: object({...}) and tuple([...]). The escape hatch any defers checking and should be read as a confession.
Brikman introduces constraints the first time a server becomes configurable (ch. 2, pp. 114-117): a number port, a typed list, and from then on plan rejects a string where a number belongs. Terraform converts where safe ("8080" satisfies number) and refuses where not, at plan, before the provider ever sees a value.
Three modifiers refine the promise. default makes an input optional at the variable level. nullable = false forbids an explicit null, which otherwise sails through any type. sensitive = true keeps the value out of plan output, which matters exactly where the pipeline posts plans to pull requests.
The structural family is where module interfaces earn their keep, and where a sharp edge sat for years: every attribute of an object was required. Terraform 1.3 closed the gap with optional():
variable "service" {
type = object({
name = string
port = number
health_check = optional(object({
path = optional(string, "/healthz")
interval = optional(number, 30)
}))
tags = optional(map(string), {})
})
}
Read the promise as a caller. name and port: mandatory. tags: omit it, receive an empty map. health_check: omit it, receive null; supply it partially and the omitted inner fields fill from their defaults. One rule keeps the semantics clean: optional(type) without a default yields null and downstream code must handle the hole, while optional(type, default) fills it so downstream code never sees one.
Constraints police shape; legality needs the validation block, which Brikman shows against instance types (ch. 8, p. 447): a condition plus an error_message. The condition may reference only the variable it validates, the rule the cert arc drilled on 07-31; cross-variable law belongs to preconditions. Where a condition must attempt something that can fail, can() wraps the attempt into a boolean and try() returns the first argument that evaluates. Both convert "error" into "value," which is what a condition expression needs.
§ IIICode Worked Example — Typing the Service Module
The Hedronite service module, interface first. This is the file a caller reads, and the file the pipeline's first gate checks:
variable "service" {
description = "One deployable service: identity, port, optional health checking."
nullable = false
type = object({
name = string
port = number
health_check = optional(object({
path = optional(string, "/healthz")
interval = optional(number, 30)
}))
tags = optional(map(string), {})
})
validation {
condition = can(regex("^[a-z][a-z0-9-]{2,31}$", var.service.name))
error_message = "service.name must be 3-32 chars: lowercase alphanumerics and hyphens, starting with a letter."
}
validation {
condition = var.service.port > 1024 && var.service.port < 65536
error_message = "service.port must sit in the unprivileged range 1025-65535."
}
validation {
condition = (
var.service.health_check == null ||
try(var.service.health_check.interval, 30) >= 5
)
error_message = "health_check.interval below 5 seconds hammers the target; raise it."
}
}
The body then consumes a value it can trust:
locals {
hc = coalesce(var.service.health_check, {
path = "/healthz"
interval = 30
})
}
resource "aws_lb_target_group" "svc" {
name = var.service.name
port = var.service.port
protocol = "HTTP"
vpc_id = var.vpc_id
health_check {
path = local.hc.path
interval = local.hc.interval
}
tags = var.service.tags
}
Two details deserve the attention. The third validation tolerates the absent health_check by short-circuiting on null before touching an attribute, with try() standing guard on the nested read; conditions run against every legal shape of the input, so the condition itself must be total. And the locals block converts the null case into a concrete default object exactly once, so no resource block ever writes try() again. Normalize at the border, trust past it.
module "billing_api" {
source = "git::https://github.com/hedronite/modules.git//service?ref=v2.1.0"
service = {
name = "billing-api"
port = 8443
health_check = { interval = 15 }
}
}
A wrong shape fails validate in the pull request in seconds. A legal shape with an illegal value fails plan with the module author's own sentence explaining why. The provider's four-hundred-page error vocabulary never enters the conversation.
§ IVConnection to Today's Ops Lesson
The Ops lesson built four gates and priced them: validate costs seconds and no credentials; the gated apply costs a human approval and a change window. The typed contract moves as much law as possible into the cheapest gate. An untyped module enforces its interface wherever the provider happens to notice, which is the most expensive gate available. A typed one enforces it where enforcement costs a pull-request comment, so the reviewer spends attention on the world-diff instead of guessing whether a port string parses.
§ VPrior-Lesson Reach
Within the TF-day Dev shelf the arc has three limbs. The 07-25 HCL lesson supplied the machinery this lesson constrains: the for_each that fans a typed map of services into a fleet now fans values the type system already vetted. The 07-31 terratest lesson tests the same contract from outside, renting real infrastructure to prove the module keeps its promise; types prove the caller's side, terratest proves the callee's. The 07-28 terrasnek lesson drives runs through the API, and every workspace variable it writes lands against these constraints at the next plan. One interface, three enforcement points.
§ VIClosing
Type every variable; treat any as a debt with a comment naming the creditor. Reach for object with optional() defaults before two loosely coupled variables. Give every business rule a validation block whose error message says what to do. Keep conditions total. Normalize once in locals; trust thereafter.
Take one module you own and retype its loosest variable as a full object contract with optional() defaults and two validations. Then feed it garbage on a branch and watch where the failure lands: the pull request, in seconds, in your own words.
Filed 2026-08-03 Fajr · Dev lesson · HCL depth, TF deep-mastery track (day 12, visit 5)